예제 #1
0
    def test_set_relative_position(self, _init_pygame, default_ui_manager,
                                   _display_surface_return_none):
        test_container = UIContainer(relative_rect=pygame.Rect(50, 50, 300, 250),
                                     manager=default_ui_manager)
        scroll_bar = UIVerticalScrollBar(relative_rect=pygame.Rect(80, 100, 30, 200),
                                         visible_percentage=0.25, manager=default_ui_manager,
                                         container=test_container)

        scroll_bar.set_relative_position((50, 50))

        # try to click on the scroll bar's top button
        default_ui_manager.process_events(pygame.event.Event(pygame.MOUSEBUTTONDOWN,
                                                             {'button': 1, 'pos': (115, 105)}))
        # if we successfully clicked on the moved scroll bar then this button should be True
        assert scroll_bar.top_button.held is True

        default_ui_manager.process_events(pygame.event.Event(pygame.MOUSEBUTTONDOWN,
                                                             {'button': 1, 'pos': (115, 295)}))
        # if we successfully clicked on the moved scroll bar then this button should be True
        assert scroll_bar.bottom_button.held is True

        default_ui_manager.process_events(pygame.event.Event(pygame.MOUSEBUTTONDOWN,
                                                             {'button': 1, 'pos': (115, 150)}))
        # if we successfully clicked on the moved scroll bar then this button should be True
        assert scroll_bar.sliding_button.held is True
 def test_rebuild(self, _init_pygame, default_ui_manager):
     scroll_bar = UIVerticalScrollBar(relative_rect=pygame.Rect(
         100, 100, 30, 150),
                                      visible_percentage=0.7,
                                      manager=default_ui_manager)
     scroll_bar.rebuild()
     assert scroll_bar.image is not None
예제 #3
0
 def test_redraw_scroll_bar(self, _init_pygame, default_ui_manager,
                            _display_surface_return_none):
     scroll_bar = UIVerticalScrollBar(relative_rect=pygame.Rect(100, 100, 30, 150),
                                      visible_percentage=0.7,
                                      manager=default_ui_manager)
     scroll_bar.redraw_scrollbar()
     assert scroll_bar.sliding_button is not None
예제 #4
0
 def test_reset_scroll_position(self, _init_pygame, default_ui_manager,
                                _display_surface_return_none):
     scroll_bar = UIVerticalScrollBar(relative_rect=pygame.Rect(100, 100, 30, 150),
                                      visible_percentage=0.7,
                                      manager=default_ui_manager)
     scroll_bar.reset_scroll_position()
     assert scroll_bar.scroll_position == 0.0 and scroll_bar.start_percentage == 0.0
예제 #5
0
    def test_process_event(self, _init_pygame, default_ui_manager,
                           _display_surface_return_none):
        scroll_bar = UIVerticalScrollBar(relative_rect=pygame.Rect(100, 100, 30, 150),
                                         visible_percentage=0.7,
                                         manager=default_ui_manager)
        scroll_bar.hovered = True
        assert scroll_bar.process_event(pygame.event.Event(pygame.MOUSEWHEEL, {'y': 0.5})) is True

        assert scroll_bar.process_event(pygame.event.Event(pygame.MOUSEWHEEL, {'y': -0.5})) is True
예제 #6
0
    def test_check_has_moved_recently(self, _init_pygame, default_ui_manager,
                                      _display_surface_return_none):
        scroll_bar = UIVerticalScrollBar(relative_rect=pygame.Rect(100, 100, 30, 150),
                                         visible_percentage=0.7,
                                         manager=default_ui_manager)

        # move the scroll bar a bit
        scroll_bar.bottom_button.held = True
        scroll_bar.update(0.2)
        assert scroll_bar.check_has_moved_recently() is True
    def test_kill(self, _init_pygame, default_ui_manager):
        scroll_bar = UIVerticalScrollBar(relative_rect=pygame.Rect(
            100, 100, 30, 150),
                                         visible_percentage=0.7,
                                         manager=default_ui_manager)

        # should kill everything
        scroll_bar.kill()

        assert scroll_bar.alive() is False and scroll_bar.sliding_button.alive(
        ) is False
예제 #8
0
    def test_set_dimensions(self, _init_pygame, default_ui_manager,
                            _display_surface_return_none):
        scroll_bar = UIVerticalScrollBar(relative_rect=pygame.Rect(0, 100, 30, 200),
                                         visible_percentage=0.25, manager=default_ui_manager)

        scroll_bar.set_dimensions((60, 100))

        # try to click on the slider
        default_ui_manager.process_events(pygame.event.Event(pygame.MOUSEBUTTONDOWN,
                                                             {'button': 1, 'pos': (40, 195)}))
        # if we successfully clicked on the moved slider then this button should be True
        assert scroll_bar.bottom_button.held is True
예제 #9
0
 def test_creation(self, _init_pygame, default_ui_manager,
                   _display_surface_return_none):
     scroll_bar = UIVerticalScrollBar(relative_rect=pygame.Rect(
         100, 100, 30, 150),
                                      visible_percentage=0.7,
                                      manager=default_ui_manager)
     assert scroll_bar.image is not None
예제 #10
0
    def test_set_visible_percentage(self, _init_pygame, default_ui_manager,
                                    _display_surface_return_none):
        scroll_bar = UIVerticalScrollBar(relative_rect=pygame.Rect(100, 100, 30, 150),
                                         visible_percentage=0.7,
                                         manager=default_ui_manager)
        scroll_bar.start_percentage = 0.9
        scroll_bar.set_visible_percentage(0.2)
        assert scroll_bar.visible_percentage == 0.2

        scroll_bar.set_visible_percentage(-0.2)
        assert scroll_bar.visible_percentage == 0.0

        scroll_bar.set_visible_percentage(1.9)
        assert scroll_bar.visible_percentage == 1.0
예제 #11
0
    def test_rebuild_from_theme_data_bad_values(self, _init_pygame,
                                                _display_surface_return_none):
        manager = UIManager((800, 600), os.path.join("tests", "data",
                                                     "themes",
                                                     "ui_vertical_scroll_bar_bad_values.json"))

        scroll_bar = UIVerticalScrollBar(relative_rect=pygame.Rect(100, 100, 30, 150),
                                         visible_percentage=1.0,
                                         manager=manager)
        assert scroll_bar.image is not None
예제 #12
0
    def test_hide(self, _init_pygame, default_ui_manager, _display_surface_return_none):
        scroll_bar = UIVerticalScrollBar(relative_rect=pygame.Rect(100, 0, 200, 30),
                                         visible_percentage=0.25, manager=default_ui_manager)

        assert scroll_bar.visible == 1

        assert scroll_bar.button_container.visible == 1
        assert scroll_bar.sliding_button.visible == 1
        assert scroll_bar.top_button.visible == 1
        assert scroll_bar.bottom_button.visible == 1

        scroll_bar.hide()

        assert scroll_bar.visible == 0

        assert scroll_bar.button_container.visible == 0
        assert scroll_bar.sliding_button.visible == 0
        assert scroll_bar.top_button.visible == 0
        assert scroll_bar.bottom_button.visible == 0
    def test_rebuild_from_theme_data_non_default(self, _init_pygame):
        manager = UIManager(
            (800, 600),
            os.path.join("tests", "data", "themes",
                         "ui_vertical_scroll_bar_non_default.json"))

        scroll_bar = UIVerticalScrollBar(relative_rect=pygame.Rect(
            100, 100, 30, 150),
                                         visible_percentage=0.1,
                                         manager=manager)
        assert scroll_bar.image is not None
예제 #14
0
    def test_kill(self, _init_pygame, default_ui_manager: IUIManagerInterface,
                  _display_surface_return_none):
        scroll_bar = UIVerticalScrollBar(relative_rect=pygame.Rect(100, 100, 30, 150),
                                         visible_percentage=0.7,
                                         manager=default_ui_manager)

        assert len(default_ui_manager.get_root_container().elements) == 2
        assert len(default_ui_manager.get_sprite_group().sprites()) == 6
        scroll_bar_sprites = [default_ui_manager.get_root_container(),
                              scroll_bar,
                              scroll_bar.button_container,
                              scroll_bar.top_button,
                              scroll_bar.bottom_button,
                              scroll_bar.sliding_button]
        assert default_ui_manager.get_sprite_group().sprites() == scroll_bar_sprites
        scroll_bar.kill()
        assert len(default_ui_manager.get_root_container().elements) == 0
        assert len(default_ui_manager.get_sprite_group().sprites()) == 1
        empty_sprites = [default_ui_manager.get_root_container()]
        assert default_ui_manager.get_sprite_group().sprites() == empty_sprites
예제 #15
0
    def test_last_focus_vert_scrollbar(self, _init_pygame, default_ui_manager):
        test_scroll_bar = UIVerticalScrollBar(relative_rect=pygame.Rect(100, 100, 30, 150),
                                              visible_percentage=0.5,
                                              manager=default_ui_manager)

        default_ui_manager.select_focus_element(test_scroll_bar)
        found_bar = test_scroll_bar is default_ui_manager.get_last_focused_vert_scrollbar()
        default_ui_manager.clear_last_focused_from_vert_scrollbar(test_scroll_bar)
        no_last_focused_scroll_bar = default_ui_manager.get_last_focused_vert_scrollbar() is None

        assert found_bar is True and no_last_focused_scroll_bar is True
예제 #16
0
    def test_show_hide_rendering(self, _init_pygame, default_ui_manager, _display_surface_return_none):
        resolution = (400, 400)
        empty_surface = pygame.Surface(resolution)
        empty_surface.fill(pygame.Color(0, 0, 0))

        surface = empty_surface.copy()
        manager = UIManager(resolution)

        scroll_bar = UIVerticalScrollBar(relative_rect=pygame.Rect(100, 100, 400, 400),
                                         visible_percentage=0.25,
                                         manager=manager,
                                         visible=0)
        manager.update(0.01)
        manager.draw_ui(surface)
        assert compare_surfaces(empty_surface, surface)

        surface.fill(pygame.Color(0, 0, 0))
        scroll_bar.show()
        manager.update(0.01)
        manager.draw_ui(surface)
        assert not compare_surfaces(empty_surface, surface)

        surface.fill(pygame.Color(0, 0, 0))
        scroll_bar.hide()
        manager.update(0.01)
        manager.draw_ui(surface)
        assert compare_surfaces(empty_surface, surface)
예제 #17
0
    def test_check_update_buttons(self, _init_pygame, default_ui_manager,
                                  _display_surface_return_none):
        scroll_bar = UIVerticalScrollBar(relative_rect=pygame.Rect(100, 100, 30, 150),
                                         visible_percentage=0.7,
                                         manager=default_ui_manager)

        # scroll down a bit then up again to exercise update
        scroll_bar.bottom_button.held = True
        scroll_bar.update(0.3)
        scroll_bar.bottom_button.held = False
        scroll_bar.top_button.held = True
        scroll_bar.update(0.3)

        assert scroll_bar.check_has_moved_recently() is True
    def test_process_event(self, _init_pygame, default_ui_manager):
        scroll_bar = UIVerticalScrollBar(relative_rect=pygame.Rect(
            100, 100, 30, 150),
                                         visible_percentage=0.7,
                                         manager=default_ui_manager)
        scroll_bar.select()
        assert scroll_bar.process_event(
            pygame.event.Event(pygame.MOUSEWHEEL, {'y': 0.5})) is True

        assert scroll_bar.process_event(
            pygame.event.Event(pygame.MOUSEWHEEL, {'y': -0.5})) is True

        del pygame.MOUSEWHEEL

        scroll_bar.process_event(
            pygame.event.Event(pygame.MOUSEBUTTONDOWN, {'y': -0.5}))

        assert pygame.MOUSEWHEEL == -1
예제 #19
0
    def test_enable(self, _init_pygame: None, default_ui_manager: UIManager,
                    _display_surface_return_none: None):
        scroll_bar = UIVerticalScrollBar(relative_rect=pygame.Rect(0, 100, 30, 200),
                                         visible_percentage=0.25, manager=default_ui_manager)

        scroll_bar.disable()
        scroll_bar.enable()

        # process a mouse button down event
        scroll_bar.bottom_button.process_event(
            pygame.event.Event(pygame.MOUSEBUTTONDOWN,
                               {'button': 1, 'pos': scroll_bar.bottom_button.rect.center}))

        scroll_bar.update(0.1)

        # process a mouse button up event
        scroll_bar.bottom_button.process_event(
            pygame.event.Event(pygame.MOUSEBUTTONUP,
                               {'button': 1, 'pos': scroll_bar.bottom_button.rect.center}))

        assert scroll_bar.scroll_position != 0.0 and scroll_bar.is_enabled is True
예제 #20
0
    def test_check_update_sliding_bar(self, _init_pygame, default_ui_manager,
                                      _display_surface_return_none):
        scroll_bar = UIVerticalScrollBar(relative_rect=pygame.Rect(0, 0, 30, 150),
                                         visible_percentage=0.7,
                                         manager=default_ui_manager)

        # scroll down a bit then up again to exercise update
        default_ui_manager.mouse_position = (15, 100)
        scroll_bar.sliding_button.held = True
        scroll_bar.update(0.3)

        assert scroll_bar.grabbed_slider is True

        scroll_bar.sliding_button.held = False
        scroll_bar.update(0.3)

        assert scroll_bar.grabbed_slider is False
예제 #21
0
    def rebuild(self):
        """
        Rebuild whatever needs building.

        """
        ''' The text_wrap_area is the part of the text box that we try to keep the text inside of so that none 
            of it overlaps. Essentially we start with the containing box, subtract the border,  then subtract 
            the padding, then if necessary subtract the width of the scroll bar'''
        self.rounded_corner_offset = int(self.shape_corner_radius -
                                         (math.sin(math.pi / 4) *
                                          self.shape_corner_radius))
        self.text_wrap_rect = [
            (self.rect[0] + self.padding[0] + self.border_width +
             self.shadow_width + self.rounded_corner_offset),
            (self.rect[1] + self.padding[1] + self.border_width +
             self.shadow_width + self.rounded_corner_offset),
            (self.rect[2] - (self.padding[0] * 2) - (self.border_width * 2) -
             (self.shadow_width * 2) - (2 * self.rounded_corner_offset)),
            (self.rect[3] - (self.padding[1] * 2) - (self.border_width * 2) -
             (self.shadow_width * 2) - (2 * self.rounded_corner_offset))
        ]
        if self.rect[3] == -1:
            self.text_wrap_rect[3] = -1

        self.parse_html_into_style_data(
        )  # This gives us the height of the text at the 'width' of the text_wrap_area
        if self.formatted_text_block is not None:
            if self.wrap_to_height or self.rect[3] == -1:
                final_text_area_size = self.formatted_text_block.final_dimensions
                self.rect.size = [
                    (final_text_area_size[0] + (self.padding[0] * 2) +
                     (self.border_width * 2) + (self.shadow_width * 2) +
                     (2 * self.rounded_corner_offset)),
                    (final_text_area_size[1] + (self.padding[1] * 2) +
                     (self.border_width * 2) + (self.shadow_width * 2) +
                     (2 * self.rounded_corner_offset))
                ]

            elif self.formatted_text_block.final_dimensions[
                    1] > self.text_wrap_rect[3]:
                # We need a scrollbar because our text is longer than the space we have to display it.
                # this also means we need to parse the text again.
                text_rect_width = (self.rect[2] - (self.padding[0] * 2) -
                                   (self.border_width * 2) -
                                   (self.shadow_width * 2) -
                                   self.rounded_corner_offset -
                                   self.scroll_bar_width)
                self.text_wrap_rect = [
                    (self.rect[0] + self.padding[0] + self.border_width +
                     self.shadow_width + self.rounded_corner_offset),
                    (self.rect[1] + self.padding[1] + self.border_width +
                     self.shadow_width + self.rounded_corner_offset),
                    text_rect_width,
                    (self.rect[3] - (self.padding[1] * 2) -
                     (self.border_width * 2) - (self.shadow_width * 2) -
                     (2 * self.rounded_corner_offset))
                ]
                self.parse_html_into_style_data()
                percentage_visible = self.text_wrap_rect[
                    3] / self.formatted_text_block.final_dimensions[1]
                scroll_bar_position = (self.relative_rect.right -
                                       self.border_width - self.shadow_width -
                                       self.scroll_bar_width,
                                       self.relative_rect.top +
                                       self.border_width + self.shadow_width)

                if self.scroll_bar is not None:
                    self.scroll_bar.kill()
                self.scroll_bar = UIVerticalScrollBar(pygame.Rect(
                    scroll_bar_position,
                    (self.scroll_bar_width, self.rect.height -
                     (2 * self.border_width) - (2 * self.shadow_width))),
                                                      percentage_visible,
                                                      self.ui_manager,
                                                      self.ui_container,
                                                      parent_element=self)
            else:
                self.rect.size = [self.rect[2], self.rect[3]]

        theming_parameters = {
            'normal_bg': self.background_colour,
            'normal_border': self.border_colour,
            'border_width': self.border_width,
            'shadow_width': self.shadow_width,
            'shape_corner_radius': self.shape_corner_radius
        }

        if self.shape_type == 'rectangle':
            self.drawable_shape = RectDrawableShape(self.rect,
                                                    theming_parameters,
                                                    ['normal'],
                                                    self.ui_manager)
        elif self.shape_type == 'rounded_rectangle':
            self.drawable_shape = RoundedRectangleShape(
                self.rect, theming_parameters, ['normal'], self.ui_manager)

        self.background_surf = self.drawable_shape.get_surface('normal')

        if self.scroll_bar is not None:
            height_adjustment = int(
                self.scroll_bar.start_percentage *
                self.formatted_text_block.final_dimensions[1])
        else:
            height_adjustment = 0

        drawable_area = pygame.Rect(
            (0, height_adjustment),
            (self.text_wrap_rect[2], self.text_wrap_rect[3]))
        self.image = pygame.Surface(self.rect.size,
                                    flags=pygame.SRCALPHA,
                                    depth=32)
        self.image.fill(pygame.Color(0, 0, 0, 0))
        self.image.blit(self.background_surf, (0, 0))
        self.image.blit(
            self.formatted_text_block.block_sprite,
            (self.padding[0] + self.border_width + self.shadow_width +
             self.rounded_corner_offset, self.padding[1] + self.border_width +
             self.shadow_width + self.rounded_corner_offset), drawable_area)

        self.formatted_text_block.add_chunks_to_hover_group(
            self.link_hover_chunks)
class UISelectionList(UIElement):
    """
    A rectangular element that holds any number of selectable text items displayed as a list.

    :param relative_rect: The positioning and sizing rectangle for the panel. See the layout guide
                          for details.
    :param item_list: A list of items as strings (item name only), or tuples of two strings (name,
                      theme_object_id).
    :param manager: The GUI manager that handles drawing and updating the UI and interactions
                    between elements.
    :param allow_multi_select: True if we are allowed to pick multiple things from the selection
                               list.
    :param allow_double_clicks: True if we can double click on items in the selection list.
    :param container: The container this element is inside of (by default the root container)
                      distinct from this panel's container.
    :param starting_height: The starting height up from it's container where this list is placed
                            into a layer.
    :param parent_element: A hierarchical 'parent' used for signifying belonging and used in
                           theming and events.
    :param object_id: An identifier that can be used to help distinguish this particular element
                      from others with the same hierarchy.
    :param anchors: Used to layout elements and dictate what the relative_rect is relative to.
                    Defaults to the top left.
    :param visible: Whether the element is visible by default. Warning - container visibility
                    may override this.
    """
    def __init__(self,
                 relative_rect: pygame.Rect,
                 item_list: Union[List[str], List[Tuple[str, str]]],
                 manager: IUIManagerInterface,
                 *,
                 allow_multi_select: bool = False,
                 allow_double_clicks: bool = True,
                 container: Union[IContainerLikeInterface, None] = None,
                 starting_height: int = 1,
                 parent_element: UIElement = None,
                 object_id: Union[ObjectID, str, None] = None,
                 anchors: Dict[str, str] = None,
                 visible: int = 1):

        super().__init__(relative_rect,
                         manager,
                         container,
                         starting_height=starting_height,
                         layer_thickness=1,
                         anchors=anchors,
                         visible=visible)

        self._create_valid_ids(container=container,
                               parent_element=parent_element,
                               object_id=object_id,
                               element_id='selection_list')

        self._parent_element = parent_element
        self.list_and_scroll_bar_container = None
        self.item_list_container = None
        self._raw_item_list = item_list
        self.item_list = []
        self.allow_multi_select = allow_multi_select
        self.allow_double_clicks = allow_double_clicks

        self.background_colour = None
        self.border_colour = None
        self.background_image = None
        self.border_width = 1
        self.shadow_width = 2
        self.shape_corner_radius = 0
        self.shape = 'rectangle'

        self.scroll_bar = None  # type: Union[UIVerticalScrollBar, None]
        self.lowest_list_pos = 0
        self.total_height_of_list = 0
        self.list_item_height = 20
        self.scroll_bar_width = 20
        self.current_scroll_bar_width = 0

        self.rebuild_from_changed_theme_data()

    def get_single_selection(self) -> Union[str, None]:
        """
        Get the selected item in a list, if any. Only works if this is a single-selection list.

        :return: A single item name as a string or None.

        """
        if not self.allow_multi_select:
            selected_list = [
                item['text'] for item in self.item_list if item['selected']
            ]
            if len(selected_list) == 1:
                return selected_list[0]
            elif len(selected_list) == 0:
                return None
            else:
                raise RuntimeError(
                    'More than one item selected in single-selection,'
                    ' selection list')
        else:
            raise RuntimeError('Requesting single selection,'
                               ' from multi-selection list')

    def get_multi_selection(self) -> List[str]:
        """
        Get all the selected items in our selection list. Only works if this is a
        multi-selection list.

        :return: A list of the selected items in our selection list. May be empty if nothing
                 selected.

        """
        if self.allow_multi_select:
            return [
                item['text'] for item in self.item_list if item['selected']
            ]
        else:
            raise RuntimeError(
                'Requesting multi selection, from single-selection list')

    def update(self, time_delta: float):
        """
        A method called every update cycle of our application. Designed to be overridden by
        derived classes but also has a little functionality to make sure the panel's layer
        'thickness' is accurate and to handle window resizing.

        :param time_delta: time passed in seconds between one call to this method and the next.

        """
        super().update(time_delta)

        if self.scroll_bar is not None and self.scroll_bar.check_has_moved_recently(
        ):
            list_height_adjustment = min(
                self.scroll_bar.start_percentage * self.total_height_of_list,
                self.lowest_list_pos)
            for index, item in enumerate(self.item_list):
                new_height = int((index * self.list_item_height) -
                                 list_height_adjustment)
                if (-self.list_item_height <= new_height <=
                        self.item_list_container.relative_rect.height):
                    if item['button_element'] is not None:
                        item['button_element'].set_relative_position(
                            (0, new_height))
                    else:
                        button_rect = pygame.Rect(
                            0, new_height,
                            self.item_list_container.relative_rect.width,
                            self.list_item_height)
                        button = UIButton(
                            relative_rect=button_rect,
                            text=item['text'],
                            manager=self.ui_manager,
                            parent_element=self,
                            container=self.item_list_container,
                            object_id=ObjectID(
                                object_id=item['object_id'],
                                class_id='@selection_list_item'),
                            allow_double_clicks=self.allow_double_clicks,
                            anchors={
                                'left': 'left',
                                'right': 'right',
                                'top': 'top',
                                'bottom': 'top'
                            })
                        self.join_focus_sets(button)
                        item['button_element'] = button
                        if item['selected']:
                            item['button_element'].select()
                else:
                    if item['button_element'] is not None:
                        item['button_element'].kill()
                        item['button_element'] = None

    def set_item_list(self, new_item_list: Union[List[str], List[Tuple[str,
                                                                       str]]]):
        """
        Set a new string list (or tuple of strings & ids list) as the item list for this selection
        list. This will change what is displayed in the list.

        Tuples should be arranged like so:

         (list_text, object_ID)

         - list_text: displayed in the UI
         - object_ID: used for theming and events

        :param new_item_list: The new list to switch to. Can be a list of strings or tuples.

        """
        self._raw_item_list = new_item_list
        self.item_list = []  # type: List[Dict]
        for new_item in new_item_list:
            if isinstance(new_item, str):
                new_item_list_item = {
                    'text': new_item,
                    'button_element': None,
                    'selected': False,
                    'object_id': '#item_list_item'
                }
            elif isinstance(new_item, tuple):
                new_item_list_item = {
                    'text': new_item[0],
                    'button_element': None,
                    'selected': False,
                    'object_id': new_item[1]
                }
            else:
                raise ValueError('Invalid item list')

            self.item_list.append(new_item_list_item)

        self.total_height_of_list = self.list_item_height * len(self.item_list)
        self.lowest_list_pos = (
            self.total_height_of_list -
            self.list_and_scroll_bar_container.relative_rect.height)
        inner_visible_area_height = self.list_and_scroll_bar_container.relative_rect.height

        if self.total_height_of_list > inner_visible_area_height:
            # we need a scroll bar
            self.current_scroll_bar_width = self.scroll_bar_width
            percentage_visible = inner_visible_area_height / max(
                self.total_height_of_list, 1)

            if self.scroll_bar is not None:
                self.scroll_bar.reset_scroll_position()
                self.scroll_bar.set_visible_percentage(percentage_visible)
                self.scroll_bar.start_percentage = 0
            else:
                self.scroll_bar = UIVerticalScrollBar(
                    pygame.Rect(-self.scroll_bar_width, 0,
                                self.scroll_bar_width,
                                inner_visible_area_height),
                    visible_percentage=percentage_visible,
                    manager=self.ui_manager,
                    parent_element=self,
                    container=self.list_and_scroll_bar_container,
                    anchors={
                        'left': 'right',
                        'right': 'right',
                        'top': 'top',
                        'bottom': 'bottom'
                    })
                self.join_focus_sets(self.scroll_bar)
        else:
            if self.scroll_bar is not None:
                self.scroll_bar.kill()
                self.scroll_bar = None
            self.current_scroll_bar_width = 0

        # create button list container
        if self.item_list_container is not None:
            self.item_list_container.clear()
            if (self.item_list_container.relative_rect.width !=
                (self.list_and_scroll_bar_container.relative_rect.width -
                 self.current_scroll_bar_width)):
                container_dimensions = (
                    self.list_and_scroll_bar_container.relative_rect.width -
                    self.current_scroll_bar_width,
                    self.list_and_scroll_bar_container.relative_rect.height)
                self.item_list_container.set_dimensions(container_dimensions)
        else:
            self.item_list_container = UIContainer(
                pygame.Rect(
                    0, 0,
                    self.list_and_scroll_bar_container.relative_rect.width -
                    self.current_scroll_bar_width,
                    self.list_and_scroll_bar_container.relative_rect.height),
                manager=self.ui_manager,
                starting_height=0,
                parent_element=self,
                container=self.list_and_scroll_bar_container,
                object_id='#item_list_container',
                anchors={
                    'left': 'left',
                    'right': 'right',
                    'top': 'top',
                    'bottom': 'bottom'
                })
            self.join_focus_sets(self.item_list_container)
        item_y_height = 0
        for item in self.item_list:
            if item_y_height <= self.item_list_container.relative_rect.height:
                button_rect = pygame.Rect(
                    0, item_y_height,
                    self.item_list_container.relative_rect.width,
                    self.list_item_height)
                item['button_element'] = UIButton(
                    relative_rect=button_rect,
                    text=item['text'],
                    manager=self.ui_manager,
                    parent_element=self,
                    container=self.item_list_container,
                    object_id=ObjectID(object_id=item['object_id'],
                                       class_id='@selection_list_item'),
                    allow_double_clicks=self.allow_double_clicks,
                    anchors={
                        'left': 'left',
                        'right': 'right',
                        'top': 'top',
                        'bottom': 'top'
                    })
                self.join_focus_sets(item['button_element'])
                item_y_height += self.list_item_height
            else:
                break

    def process_event(self, event: pygame.event.Event) -> bool:
        """
        Can be overridden, also handle resizing windows. Gives UI Windows access to pygame events.
        Currently just blocks mouse click down events from passing through the panel.

        :param event: The event to process.

        :return: Should return True if this element makes use of this event.

        """
        if self.is_enabled and (
                event.type == pygame.USEREVENT and event.user_type
                in [UI_BUTTON_PRESSED, UI_BUTTON_DOUBLE_CLICKED]
                and event.ui_element in self.item_list_container.elements):
            for item in self.item_list:
                if item['button_element'] == event.ui_element:
                    if event.user_type == UI_BUTTON_DOUBLE_CLICKED:
                        event_data = {
                            'user_type':
                            UI_SELECTION_LIST_DOUBLE_CLICKED_SELECTION,
                            'text': event.ui_element.text,
                            'ui_element': self,
                            'ui_object_id': self.most_specific_combined_id
                        }
                    else:
                        if item['selected']:
                            item['selected'] = False
                            event.ui_element.unselect()

                            event_data = {
                                'user_type':
                                UI_SELECTION_LIST_DROPPED_SELECTION,
                                'text': event.ui_element.text,
                                'ui_element': self,
                                'ui_object_id': self.most_specific_combined_id
                            }

                        else:
                            item['selected'] = True
                            event.ui_element.select()

                            event_data = {
                                'user_type': UI_SELECTION_LIST_NEW_SELECTION,
                                'text': event.ui_element.text,
                                'ui_element': self,
                                'ui_object_id': self.most_specific_combined_id
                            }

                    selection_list_event = pygame.event.Event(
                        pygame.USEREVENT, event_data)
                    pygame.event.post(selection_list_event)
                elif not self.allow_multi_select:
                    if item['selected']:
                        item['selected'] = False
                        if item['button_element'] is not None:
                            item['button_element'].unselect()

                            event_data = {
                                'user_type':
                                UI_SELECTION_LIST_DROPPED_SELECTION,
                                'text': item['text'],
                                'ui_element': self,
                                'ui_object_id': self.most_specific_combined_id
                            }
                            drop_down_changed_event = pygame.event.Event(
                                pygame.USEREVENT, event_data)
                            pygame.event.post(drop_down_changed_event)

        return False  # Don't consume any events

    def set_dimensions(self, dimensions: Union[pygame.math.Vector2,
                                               Tuple[int, int], Tuple[float,
                                                                      float]]):
        """
        Set the size of this panel and then resizes and shifts the contents of the panel container
        to fit the new size.

        :param dimensions: The new dimensions to set.

        """
        # Don't use a basic gate on this set dimensions method because the container may be a
        # different size to the window
        super().set_dimensions(dimensions)

        border_and_shadow = self.border_width + self.shadow_width
        container_width = self.relative_rect.width - (2 * border_and_shadow)
        container_height = self.relative_rect.height - (2 * border_and_shadow)
        self.list_and_scroll_bar_container.set_dimensions(
            (container_width, container_height))

    def set_relative_position(self, position: Union[pygame.math.Vector2,
                                                    Tuple[int, int],
                                                    Tuple[float, float]]):
        """
        Method to directly set the relative rect position of an element.

        :param position: The new position to set.

        """
        super().set_relative_position(position)
        border_and_shadow = self.border_width + self.shadow_width
        container_left = self.relative_rect.left + border_and_shadow
        container_top = self.relative_rect.top + border_and_shadow
        self.list_and_scroll_bar_container.set_relative_position(
            (container_left, container_top))

    def set_position(self, position: Union[pygame.math.Vector2,
                                           Tuple[int, int], Tuple[float,
                                                                  float]]):
        """
        Sets the absolute screen position of this slider, updating all subordinate button
        elements at the same time.

        :param position: The absolute screen position to set.

        """
        super().set_position(position)
        border_and_shadow = self.border_width + self.shadow_width
        container_left = self.relative_rect.left + border_and_shadow
        container_top = self.relative_rect.top + border_and_shadow
        self.list_and_scroll_bar_container.set_relative_position(
            (container_left, container_top))

    def kill(self):
        """
        Overrides the basic kill() method of a pygame sprite so that we also kill all the UI
        elements in this panel.

        """
        self.list_and_scroll_bar_container.kill()
        super().kill()

    def rebuild_from_changed_theme_data(self):
        """
        Checks if any theming parameters have changed, and if so triggers a full rebuild of the
        button's drawable shape
        """
        super().rebuild_from_changed_theme_data()
        has_any_changed = False

        background_colour = self.ui_theme.get_colour_or_gradient(
            'dark_bg', self.combined_element_ids)
        if background_colour != self.background_colour:
            self.background_colour = background_colour
            has_any_changed = True

        border_colour = self.ui_theme.get_colour_or_gradient(
            'normal_border', self.combined_element_ids)
        if border_colour != self.border_colour:
            self.border_colour = border_colour
            has_any_changed = True

        # misc
        if self._check_misc_theme_data_changed(
                attribute_name='shape',
                default_value='rectangle',
                casting_func=str,
                allowed_values=['rectangle', 'rounded_rectangle']):
            has_any_changed = True

        if self._check_shape_theming_changed(defaults={
                'border_width': 1,
                'shadow_width': 2,
                'shape_corner_radius': 2
        }):
            has_any_changed = True

        if self._check_misc_theme_data_changed(
                attribute_name='list_item_height',
                default_value=20,
                casting_func=int):
            has_any_changed = True

        if has_any_changed:
            self.rebuild()

    def rebuild(self):
        """
        A complete rebuild of the drawable shape used by this element.

        """
        theming_parameters = {
            'normal_bg': self.background_colour,
            'normal_border': self.border_colour,
            'normal_image': self.background_image,
            'border_width': self.border_width,
            'shadow_width': self.shadow_width,
            'shape_corner_radius': self.shape_corner_radius
        }

        if self.shape == 'rectangle':
            self.drawable_shape = RectDrawableShape(self.rect,
                                                    theming_parameters,
                                                    ['normal'],
                                                    self.ui_manager)
        elif self.shape == 'rounded_rectangle':
            self.drawable_shape = RoundedRectangleShape(
                self.rect, theming_parameters, ['normal'], self.ui_manager)

        self.on_fresh_drawable_shape_ready()

        if self.list_and_scroll_bar_container is None:
            self.list_and_scroll_bar_container = UIContainer(
                pygame.Rect(
                    self.relative_rect.left + self.shadow_width +
                    self.border_width, self.relative_rect.top +
                    self.shadow_width + self.border_width,
                    self.relative_rect.width - (2 * self.shadow_width) -
                    (2 * self.border_width), self.relative_rect.height -
                    (2 * self.shadow_width) - (2 * self.border_width)),
                manager=self.ui_manager,
                starting_height=self.starting_height,
                container=self.ui_container,
                parent_element=self._parent_element,
                object_id='#selection_list_container',
                anchors=self.anchors,
                visible=self.visible)
            self.join_focus_sets(self.list_and_scroll_bar_container)
        else:
            self.list_and_scroll_bar_container.set_dimensions(
                (self.relative_rect.width - (2 * self.shadow_width) -
                 (2 * self.border_width), self.relative_rect.height -
                 (2 * self.shadow_width) - (2 * self.border_width)))
            self.list_and_scroll_bar_container.set_relative_position(
                (self.relative_rect.left + self.shadow_width +
                 self.border_width, self.relative_rect.top +
                 self.shadow_width + self.border_width))

        self.set_item_list(self._raw_item_list)

    def disable(self):
        """
        Disables all elements in the selection list so they are no longer interactive.
        """
        if self.is_enabled:
            self.is_enabled = False
            self.list_and_scroll_bar_container.disable()

            # clear selections
            for item in self.item_list:
                item['selected'] = False

    def enable(self):
        """
        Enables all elements in the selection list so they are interactive again.
        """
        if not self.is_enabled:
            self.is_enabled = True
            self.list_and_scroll_bar_container.enable()

    def show(self):
        """
        In addition to the base UIElement.show() - call show() of owned container -
        list_and_scroll_bar_container. All other subelements (item_list_container, scrollbar) are
        children of list_and_scroll_bar_container, so it's visibility will propagate to them -
        there is no need to call their show() methods separately.
        """
        super().show()

        self.list_and_scroll_bar_container.show()

    def hide(self):
        """
        In addition to the base UIElement.hide() - call hide() of owned container -
        list_and_scroll_bar_container. All other subelements (item_list_container, scrollbar) are
        children of list_and_scroll_bar_container, so it's visibility will propagate to them -
        there is no need to call their hide() methods separately.
        """
        super().hide()

        self.list_and_scroll_bar_container.hide()
    def set_item_list(self, new_item_list: Union[List[str], List[Tuple[str,
                                                                       str]]]):
        """
        Set a new string list (or tuple of strings & ids list) as the item list for this selection
        list. This will change what is displayed in the list.

        Tuples should be arranged like so:

         (list_text, object_ID)

         - list_text: displayed in the UI
         - object_ID: used for theming and events

        :param new_item_list: The new list to switch to. Can be a list of strings or tuples.

        """
        self._raw_item_list = new_item_list
        self.item_list = []  # type: List[Dict]
        for new_item in new_item_list:
            if isinstance(new_item, str):
                new_item_list_item = {
                    'text': new_item,
                    'button_element': None,
                    'selected': False,
                    'object_id': '#item_list_item'
                }
            elif isinstance(new_item, tuple):
                new_item_list_item = {
                    'text': new_item[0],
                    'button_element': None,
                    'selected': False,
                    'object_id': new_item[1]
                }
            else:
                raise ValueError('Invalid item list')

            self.item_list.append(new_item_list_item)

        self.total_height_of_list = self.list_item_height * len(self.item_list)
        self.lowest_list_pos = (
            self.total_height_of_list -
            self.list_and_scroll_bar_container.relative_rect.height)
        inner_visible_area_height = self.list_and_scroll_bar_container.relative_rect.height

        if self.total_height_of_list > inner_visible_area_height:
            # we need a scroll bar
            self.current_scroll_bar_width = self.scroll_bar_width
            percentage_visible = inner_visible_area_height / max(
                self.total_height_of_list, 1)

            if self.scroll_bar is not None:
                self.scroll_bar.reset_scroll_position()
                self.scroll_bar.set_visible_percentage(percentage_visible)
                self.scroll_bar.start_percentage = 0
            else:
                self.scroll_bar = UIVerticalScrollBar(
                    pygame.Rect(-self.scroll_bar_width, 0,
                                self.scroll_bar_width,
                                inner_visible_area_height),
                    visible_percentage=percentage_visible,
                    manager=self.ui_manager,
                    parent_element=self,
                    container=self.list_and_scroll_bar_container,
                    anchors={
                        'left': 'right',
                        'right': 'right',
                        'top': 'top',
                        'bottom': 'bottom'
                    })
                self.join_focus_sets(self.scroll_bar)
        else:
            if self.scroll_bar is not None:
                self.scroll_bar.kill()
                self.scroll_bar = None
            self.current_scroll_bar_width = 0

        # create button list container
        if self.item_list_container is not None:
            self.item_list_container.clear()
            if (self.item_list_container.relative_rect.width !=
                (self.list_and_scroll_bar_container.relative_rect.width -
                 self.current_scroll_bar_width)):
                container_dimensions = (
                    self.list_and_scroll_bar_container.relative_rect.width -
                    self.current_scroll_bar_width,
                    self.list_and_scroll_bar_container.relative_rect.height)
                self.item_list_container.set_dimensions(container_dimensions)
        else:
            self.item_list_container = UIContainer(
                pygame.Rect(
                    0, 0,
                    self.list_and_scroll_bar_container.relative_rect.width -
                    self.current_scroll_bar_width,
                    self.list_and_scroll_bar_container.relative_rect.height),
                manager=self.ui_manager,
                starting_height=0,
                parent_element=self,
                container=self.list_and_scroll_bar_container,
                object_id='#item_list_container',
                anchors={
                    'left': 'left',
                    'right': 'right',
                    'top': 'top',
                    'bottom': 'bottom'
                })
            self.join_focus_sets(self.item_list_container)
        item_y_height = 0
        for item in self.item_list:
            if item_y_height <= self.item_list_container.relative_rect.height:
                button_rect = pygame.Rect(
                    0, item_y_height,
                    self.item_list_container.relative_rect.width,
                    self.list_item_height)
                item['button_element'] = UIButton(
                    relative_rect=button_rect,
                    text=item['text'],
                    manager=self.ui_manager,
                    parent_element=self,
                    container=self.item_list_container,
                    object_id=ObjectID(object_id=item['object_id'],
                                       class_id='@selection_list_item'),
                    allow_double_clicks=self.allow_double_clicks,
                    anchors={
                        'left': 'left',
                        'right': 'right',
                        'top': 'top',
                        'bottom': 'top'
                    })
                self.join_focus_sets(item['button_element'])
                item_y_height += self.list_item_height
            else:
                break
예제 #24
0
class UITextBox(UIElement):
    """
    A Text Box element lets us display word-wrapped, formatted text. If the text to display is
    longer than the height of the box given then the element will automatically create a vertical
    scroll bar so that all the text can be seen.

    Formatting the text is done via a subset of HTML tags. Currently supported tags are:

    - <b></b> or <strong></strong> - to encase bold styled text.
    - <i></i>, <em></em> or <var></var> - to encase italic styled text.
    - <u></u> - to encase underlined text.
    - <a href='id'></a> - to encase 'link' text that can be clicked on to generate events with the
                          id given in href.
    - <body bgcolor='#FFFFFF'></body> - to change the background colour of encased text.
    - <br> - to start a new line.
    - <font face='verdana' color='#000000' size=3.5></font> - To set the font, colour and size of
                                                              encased text.

    More may be added in the future if needed or frequently requested.

    NOTE: if dimensions of the initial containing rect are set to -1 the text box will match the
    final dimension to whatever the text rendering produces. This lets us make dynamically sized
    text boxes depending on their contents.


    :param html_text: The HTML formatted text to display in this text box.
    :param relative_rect: The 'visible area' rectangle, positioned relative to it's container.
    :param manager: The UIManager that manages this element.
    :param wrap_to_height: False by default, if set to True the box will increase in height to
                           match the text within.
    :param layer_starting_height: Sets the height, above it's container, to start placing the text
                                  box at.
    :param container: The container that this element is within. If set to None will be the root
                      window's container.
    :param parent_element: The element this element 'belongs to' in the theming hierarchy.
    :param object_id: A custom defined ID for fine tuning of theming.
    :param anchors: A dictionary describing what this element's relative_rect is relative to.

    """

    def __init__(self,
                 html_text: str,
                 relative_rect: pygame.Rect,
                 manager: IUIManagerInterface,
                 wrap_to_height: bool = False,
                 layer_starting_height: int = 1,
                 container: Union[IContainerLikeInterface, None] = None,
                 parent_element: UIElement = None,
                 object_id: Union[str, None] = None,
                 anchors: Dict[str, str] = None):

        new_element_ids, new_object_ids = self.create_valid_ids(container=container,
                                                                parent_element=parent_element,
                                                                object_id=object_id,
                                                                element_id='text_box')
        super().__init__(relative_rect, manager, container,
                         starting_height=layer_starting_height,
                         layer_thickness=2,
                         element_ids=new_element_ids,
                         object_ids=new_object_ids,
                         anchors=anchors
                         )
        self.html_text = html_text
        self.font_dict = self.ui_theme.get_font_dictionary()

        self.wrap_to_height = wrap_to_height
        self.link_hover_chunks = []  # container for any link chunks we have

        self.active_text_effect = None
        self.scroll_bar = None
        self.scroll_bar_width = 20

        self.border_width = None
        self.shadow_width = None
        self.padding = None
        self.background_colour = None
        self.border_colour = None

        self.link_normal_colour = None
        self.link_hover_colour = None
        self.link_selected_colour = None
        self.link_normal_underline = False
        self.link_hover_underline = True
        self.link_style = None

        self.rounded_corner_offset = None
        self.formatted_text_block = None  # TextLine()
        self.text_wrap_rect = None
        self.background_surf = None

        self.drawable_shape = None
        self.shape_type = 'rectangle'
        self.shape_corner_radius = None

        self.should_trigger_full_rebuild = True
        self.time_until_full_rebuild_after_changing_size = 0.2
        self.full_rebuild_countdown = self.time_until_full_rebuild_after_changing_size

        self.rebuild_from_changed_theme_data()

    def kill(self):
        """
        Overrides the standard sprite kill method to also kill any scroll bars belonging to this
        text box.
        """
        if self.scroll_bar is not None:
            self.scroll_bar.kill()
        super().kill()

    def rebuild(self):
        """
        Rebuild whatever needs building.

        """
        if self.scroll_bar is not None:
            self.scroll_bar.kill()

        # The text_wrap_area is the part of the text box that we try to keep the text inside
        # of so that none  of it overlaps. Essentially we start with the containing box,
        # subtract the border, then subtract the padding, then if necessary subtract the width
        # of the scroll bar
        self.rounded_corner_offset = int(self.shape_corner_radius -
                                         (math.sin(math.pi / 4) *
                                          self.shape_corner_radius))
        self.text_wrap_rect = [(self.rect[0] +
                                self.padding[0] +
                                self.border_width +
                                self.shadow_width +
                                self.rounded_corner_offset),
                               (self.rect[1] +
                                self.padding[1] +
                                self.border_width +
                                self.shadow_width +
                                self.rounded_corner_offset),
                               max(1, (self.rect[2] -
                                       (self.padding[0] * 2) -
                                       (self.border_width * 2) -
                                       (self.shadow_width * 2) -
                                       (2 * self.rounded_corner_offset))),
                               max(1, (self.rect[3] -
                                       (self.padding[1] * 2) -
                                       (self.border_width * 2) -
                                       (self.shadow_width * 2) -
                                       (2 * self.rounded_corner_offset)))]
        if self.wrap_to_height or self.rect[3] == -1:
            self.text_wrap_rect[3] = -1
        if self.rect[2] == -1:
            self.text_wrap_rect[2] = -1

        # This gives us the height of the text at the 'width' of the text_wrap_area
        self.parse_html_into_style_data()
        if self.formatted_text_block is not None:
            if self.wrap_to_height or self.rect[3] == -1 or self.rect[2] == -1:
                final_text_area_size = self.formatted_text_block.final_dimensions
                new_dimensions = ((final_text_area_size[0] + (self.padding[0] * 2) +
                                   (self.border_width * 2) + (self.shadow_width * 2) +
                                   (2 * self.rounded_corner_offset)),
                                  (final_text_area_size[1] + (self.padding[1] * 2) +
                                   (self.border_width * 2) + (self.shadow_width * 2) +
                                   (2 * self.rounded_corner_offset)))
                self.set_dimensions(new_dimensions)

            elif self.formatted_text_block.final_dimensions[1] > self.text_wrap_rect[3]:
                # We need a scrollbar because our text is longer than the space we
                # have to display it. This also means we need to parse the text again.
                text_rect_width = (self.rect[2] -
                                   (self.padding[0] * 2) -
                                   (self.border_width * 2) -
                                   (self.shadow_width * 2) -
                                   self.rounded_corner_offset - self.scroll_bar_width)
                self.text_wrap_rect = [(self.rect[0] + self.padding[0] + self.border_width +
                                        self.shadow_width + self.rounded_corner_offset),
                                       (self.rect[1] + self.padding[1] + self.border_width +
                                        self.shadow_width + self.rounded_corner_offset),
                                       max(1, text_rect_width),
                                       max(1, (self.rect[3] -
                                               (self.padding[1] * 2) -
                                               (self.border_width * 2) -
                                               (self.shadow_width * 2) -
                                               (2 * self.rounded_corner_offset)))]
                self.parse_html_into_style_data()
                percentage_visible = (self.text_wrap_rect[3] /
                                      self.formatted_text_block.final_dimensions[1])
                scroll_bar_position = (self.relative_rect.right - self.border_width -
                                       self.shadow_width - self.scroll_bar_width,
                                       self.relative_rect.top + self.border_width +
                                       self.shadow_width)

                scroll_bar_rect = pygame.Rect(scroll_bar_position,
                                              (self.scroll_bar_width,
                                               self.rect.height -
                                               (2 * self.border_width) -
                                               (2 * self.shadow_width)))
                self.scroll_bar = UIVerticalScrollBar(scroll_bar_rect,
                                                      percentage_visible,
                                                      self.ui_manager,
                                                      self.ui_container,
                                                      parent_element=self)
            else:
                new_dimensions = (self.rect[2], self.rect[3])
                self.set_dimensions(new_dimensions)

        theming_parameters = {'normal_bg': self.background_colour,
                              'normal_border': self.border_colour,
                              'border_width': self.border_width,
                              'shadow_width': self.shadow_width,
                              'shape_corner_radius': self.shape_corner_radius}

        if self.shape_type == 'rectangle':
            self.drawable_shape = RectDrawableShape(self.rect, theming_parameters,
                                                    ['normal'], self.ui_manager)
        elif self.shape_type == 'rounded_rectangle':
            self.drawable_shape = RoundedRectangleShape(self.rect, theming_parameters,
                                                        ['normal'], self.ui_manager)

        self.background_surf = self.drawable_shape.get_surface('normal')

        if self.scroll_bar is not None:
            height_adjustment = int(self.scroll_bar.start_percentage *
                                    self.formatted_text_block.final_dimensions[1])
        else:
            height_adjustment = 0

        if self.rect.width <= 0 or self.rect.height <= 0:
            return

        drawable_area = pygame.Rect((0, height_adjustment),
                                    (self.text_wrap_rect[2],
                                     self.text_wrap_rect[3]))
        new_image = pygame.Surface(self.rect.size, flags=pygame.SRCALPHA, depth=32)
        new_image.fill(pygame.Color(0, 0, 0, 0))
        new_image.blit(self.background_surf, (0, 0))
        new_image.blit(self.formatted_text_block.block_sprite,
                       (self.padding[0] + self.border_width +
                        self.shadow_width + self.rounded_corner_offset,
                        self.padding[1] + self.border_width +
                        self.shadow_width + self.rounded_corner_offset),
                       drawable_area)

        self.set_image(new_image)

        self.formatted_text_block.add_chunks_to_hover_group(self.link_hover_chunks)

        self.should_trigger_full_rebuild = False
        self.full_rebuild_countdown = self.time_until_full_rebuild_after_changing_size

    def update(self, time_delta: float):
        """
        Called once every update loop of the UI Manager. Used to react to scroll bar movement
        (if there is one), update the text effect (if there is one) and check if we are hovering
        over any text links (if there are any).

        :param time_delta: The time in seconds between calls to update. Useful for timing things.

        """
        super().update(time_delta)
        if not self.alive():
            return
        if self.scroll_bar is not None and self.scroll_bar.check_has_moved_recently():
            height_adjustment = int(self.scroll_bar.start_percentage *
                                    self.formatted_text_block.final_dimensions[1])
            drawable_area = pygame.Rect((0, height_adjustment),
                                        (self.text_wrap_rect[2], self.text_wrap_rect[3]))

            if self.rect.width <= 0 or self.rect.height <= 0:
                return

            new_image = pygame.Surface(self.rect.size, flags=pygame.SRCALPHA, depth=32)
            new_image.fill(pygame.Color(0, 0, 0, 0))
            new_image.blit(self.background_surf, (0, 0))
            new_image.blit(self.formatted_text_block.block_sprite,
                           (self.padding[0] + self.border_width +
                            self.shadow_width +
                            self.rounded_corner_offset,
                            self.padding[1] + self.border_width +
                            self.shadow_width +
                            self.rounded_corner_offset),
                           drawable_area)
            self.set_image(new_image)

        mouse_x, mouse_y = self.ui_manager.get_mouse_position()
        should_redraw_from_chunks = False

        if self.scroll_bar is not None:
            height_adjustment = (self.scroll_bar.start_percentage *
                                 self.formatted_text_block.final_dimensions[1])
        else:
            height_adjustment = 0
        base_x = int(self.rect[0] + self.padding[0] + self.border_width +
                     self.shadow_width + self.rounded_corner_offset)
        base_y = int(self.rect[1] + self.padding[1] + self.border_width +
                     self.shadow_width + self.rounded_corner_offset - height_adjustment)

        for chunk in self.link_hover_chunks:
            hovered_currently = False

            hover_rect = pygame.Rect((base_x + chunk.rect.x,
                                      base_y + chunk.rect.y),
                                     chunk.rect.size)
            if hover_rect.collidepoint(mouse_x, mouse_y) and self.rect.collidepoint(mouse_x,
                                                                                    mouse_y):
                hovered_currently = True
            if chunk.is_hovered and not hovered_currently:
                chunk.on_unhovered()
                should_redraw_from_chunks = True
            elif hovered_currently and not chunk.is_hovered:
                chunk.on_hovered()
                should_redraw_from_chunks = True

        if should_redraw_from_chunks:
            self.redraw_from_chunks()

        if self.active_text_effect is not None:
            self.active_text_effect.update(time_delta)
            if self.active_text_effect.should_full_redraw():
                self.full_redraw()
            if self.active_text_effect.should_redraw_from_chunks():
                self.redraw_from_chunks()

        if self.should_trigger_full_rebuild and self.full_rebuild_countdown <= 0.0:
            self.rebuild()

        if self.full_rebuild_countdown > 0.0:
            self.full_rebuild_countdown -= time_delta

    def on_fresh_drawable_shape_ready(self):
        """
        Called by an element's drawable shape when it has a new image surface ready for use,
        normally after a rebuilding/redrawing of some kind.
        """
        self.background_surf = self.drawable_shape.get_surface('normal')
        self.redraw_from_text_block()

    def set_relative_position(self, position: Union[pygame.math.Vector2,
                                                    Tuple[int, int],
                                                    Tuple[float, float]]):
        """
        Sets the relative screen position of this text box, updating it's subordinate scroll bar at
        the same time.

        :param position: The relative screen position to set.

        """
        super().set_relative_position(position)

        if self.scroll_bar is not None:
            scroll_bar_position = (self.relative_rect.right - self.border_width -
                                   self.shadow_width - self.scroll_bar_width,
                                   self.relative_rect.top + self.border_width +
                                   self.shadow_width)
            self.scroll_bar.set_relative_position(scroll_bar_position)

    def set_position(self, position: Union[pygame.math.Vector2,
                                           Tuple[int, int],
                                           Tuple[float, float]]):
        """
        Sets the absolute screen position of this text box, updating it's subordinate scroll bar
        at the same time.

        :param position: The absolute screen position to set.

        """
        super().set_position(position)

        if self.scroll_bar is not None:
            scroll_bar_position = (self.relative_rect.right - self.border_width -
                                   self.shadow_width - self.scroll_bar_width,
                                   self.relative_rect.top + self.border_width +
                                   self.shadow_width)
            self.scroll_bar.set_relative_position(scroll_bar_position)

    def set_dimensions(self, dimensions: Union[pygame.math.Vector2,
                                               Tuple[int, int],
                                               Tuple[float, float]]):
        """
        Method to directly set the dimensions of a text box.

        :param dimensions: The new dimensions to set.

        """
        self.relative_rect.width = int(dimensions[0])
        self.relative_rect.height = int(dimensions[1])
        self.rect.size = self.relative_rect.size

        if dimensions[0] >= 0 and dimensions[1] >= 0:
            if self.relative_right_margin is not None:
                self.relative_right_margin = self.ui_container.rect.right - self.rect.right

            if self.relative_bottom_margin is not None:
                self.relative_bottom_margin = self.ui_container.rect.bottom - self.rect.bottom

            self._update_container_clip()

            # Quick and dirty temporary scaling to cut down on number of
            # full rebuilds triggered when rapid scaling
            if self.image is not None:
                if (self.full_rebuild_countdown > 0.0 and
                        (self.relative_rect.width > 0 and
                         self.relative_rect.height > 0)):
                    new_image = pygame.Surface(self.relative_rect.size,
                                               flags=pygame.SRCALPHA,
                                               depth=32)
                    new_image.blit(self.image, (0, 0))
                    self.set_image(new_image)

                    if self.scroll_bar is not None:
                        self.scroll_bar.set_dimensions((self.scroll_bar.relative_rect.width,
                                                        self.relative_rect.height -
                                                        (2 * self.border_width) -
                                                        (2 * self.shadow_width)))
                        scroll_bar_position = (self.relative_rect.right - self.border_width -
                                               self.shadow_width - self.scroll_bar_width,
                                               self.relative_rect.top + self.border_width +
                                               self.shadow_width)
                        self.scroll_bar.set_relative_position(scroll_bar_position)

                self.should_trigger_full_rebuild = True
                self.full_rebuild_countdown = self.time_until_full_rebuild_after_changing_size

    def parse_html_into_style_data(self):
        """
        Parses HTML styled string text into a format more useful for styling pygame.font
        rendered text.
        """
        parser = TextHTMLParser(self.ui_theme, self.element_ids, self.object_ids)
        parser.push_style('body', {"bg_colour": self.background_colour})
        parser.feed(self.html_text)

        self.formatted_text_block = TextBlock(parser.text_data,
                                              self.text_wrap_rect,
                                              parser.indexed_styles,
                                              self.font_dict,
                                              self.link_style,
                                              self.background_colour,
                                              self.wrap_to_height
                                              )

    def redraw_from_text_block(self):
        """
        Redraws the final parts of the text box element that don't include redrawing the actual
        text. Useful if we've just moved the position of the text (say, with a scroll bar)
        without actually changing the text itself.
        """
        if self.rect.width <= 0 or self.rect.height <= 0:
            return
        if self.scroll_bar is not None:
            height_adjustment = int(self.scroll_bar.start_percentage *
                                    self.formatted_text_block.final_dimensions[1])
        else:
            height_adjustment = 0

        drawable_area = pygame.Rect((0, height_adjustment),
                                    (self.text_wrap_rect[2], self.text_wrap_rect[3]))
        new_image = pygame.Surface(self.rect.size, flags=pygame.SRCALPHA, depth=32)
        new_image.fill(pygame.Color(0, 0, 0, 0))
        new_image.blit(self.background_surf, (0, 0))
        new_image.blit(self.formatted_text_block.block_sprite,
                       (self.padding[0] + self.border_width +
                        self.shadow_width + self.rounded_corner_offset,
                        self.padding[1] + self.border_width +
                        self.shadow_width + self.rounded_corner_offset),
                       drawable_area)

        self.set_image(new_image)

    def redraw_from_chunks(self):
        """
        Redraws from slightly earlier in the process than 'redraw_from_text_block'. Useful if we
        have redrawn individual chunks already (say, to change their style slightly after being
        hovered) and now want to update the text block with those changes without doing a
        full redraw.

        This won't work very well if redrawing a chunk changed it's dimensions.
        """
        self.formatted_text_block.redraw_from_chunks(self.active_text_effect)
        self.redraw_from_text_block()

    def full_redraw(self):
        """
        Trigger a full redraw of the entire text box. Useful if we have messed with the text
        chunks in a more fundamental fashion and need to reposition them (say, if some of them
        have gotten wider after being made bold).

        NOTE: This doesn't re-parse the text of our box. If you need to do that, just create a
        new text box.

        """
        self.formatted_text_block.redraw(self.active_text_effect)
        self.redraw_from_text_block()
        self.link_hover_chunks = []
        self.formatted_text_block.add_chunks_to_hover_group(self.link_hover_chunks)

    def focus(self):
        """
        Called when we focus the text box (usually by clicking on it). In this case we just pass
        the focus over to the box's scroll bar, if it has one, so that some input events will be
        directed that way.
        """
        if self.scroll_bar is not None:
            self.scroll_bar.focus()

    def process_event(self, event: pygame.event.Event) -> bool:
        """
        Deals with input events. In this case we just handle clicks on any links in the text.

        :param event: A pygame event to check for a reaction to.

        :return: Returns True if we consumed this event.

        """
        consumed_event = False
        should_redraw_from_chunks = False
        should_full_redraw = False
        if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
            scaled_mouse_pos = (int(event.pos[0] * self.ui_manager.mouse_pos_scale_factor[0]),
                                int(event.pos[1] * self.ui_manager.mouse_pos_scale_factor[1]))
            if self.hover_point(scaled_mouse_pos[0], scaled_mouse_pos[1]):
                consumed_event = True
                if self.scroll_bar is not None:
                    text_block_full_height = self.formatted_text_block.final_dimensions[1]
                    height_adjustment = self.scroll_bar.start_percentage * text_block_full_height
                else:
                    height_adjustment = 0
                base_x = int(self.rect[0] + self.padding[0] + self.border_width +
                             self.shadow_width + self.rounded_corner_offset)
                base_y = int(self.rect[1] + self.padding[1] + self.border_width +
                             self.shadow_width + self.rounded_corner_offset - height_adjustment)
                for chunk in self.link_hover_chunks:

                    hover_rect = pygame.Rect((base_x + chunk.rect.x,
                                              base_y + chunk.rect.y),
                                             chunk.rect.size)
                    if hover_rect.collidepoint(scaled_mouse_pos[0], scaled_mouse_pos[1]):
                        consumed_event = True
                        if not chunk.is_selected:
                            chunk.on_selected()
                            if chunk.metrics_changed_after_redraw:
                                should_full_redraw = True
                            else:
                                should_redraw_from_chunks = True

        if event.type == pygame.MOUSEBUTTONUP and event.button == 1:
            if self.scroll_bar is not None:
                height_adjustment = (self.scroll_bar.start_percentage *
                                     self.formatted_text_block.final_dimensions[1])
            else:
                height_adjustment = 0
            base_x = int(self.rect[0] + self.padding[0] + self.border_width +
                         self.shadow_width + self.rounded_corner_offset)
            base_y = int(self.rect[1] + self.padding[1] + self.border_width +
                         self.shadow_width + self.rounded_corner_offset - height_adjustment)
            scaled_mouse_pos = (int(event.pos[0] * self.ui_manager.mouse_pos_scale_factor[0]),
                                int(event.pos[1] * self.ui_manager.mouse_pos_scale_factor[1]))
            for chunk in self.link_hover_chunks:

                hover_rect = pygame.Rect((base_x + chunk.rect.x,
                                          base_y + chunk.rect.y),
                                         chunk.rect.size)
                if (hover_rect.collidepoint(scaled_mouse_pos[0], scaled_mouse_pos[1]) and
                        self.rect.collidepoint(scaled_mouse_pos[0], scaled_mouse_pos[1])):
                    consumed_event = True
                    if chunk.is_selected:
                        event_data = {'user_type': UI_TEXT_BOX_LINK_CLICKED,
                                      'link_target': chunk.link_href,
                                      'ui_element': self,
                                      'ui_object_id': self.most_specific_combined_id}
                        pygame.event.post(pygame.event.Event(pygame.USEREVENT, event_data))

                if chunk.is_selected:
                    chunk.on_unselected()
                    if chunk.metrics_changed_after_redraw:
                        should_full_redraw = True
                    else:
                        should_redraw_from_chunks = True

        if should_redraw_from_chunks:
            self.redraw_from_chunks()

        if should_full_redraw:
            self.full_redraw()

        return consumed_event

    def set_active_effect(self, effect_name: Union[str, None]):
        """
        Set an animation effect to run on the text box. The effect will start running immediately
        after this call.

        These effects are currently supported:

        - 'typing_appear' - Will look as if the text is being typed in.
        - 'fade_in' - The text will fade in from the background colour (Only supported on Pygame 2)
        - 'fade_out' - The text will fade out to the background colour (only supported on Pygame 2)

        :param effect_name: The name fo the t to set. If set to None instead it will cancel any
                            active effect.

        """
        if effect_name is None:
            self.active_text_effect = None
        elif isinstance(effect_name, str):
            if effect_name == TEXT_EFFECT_TYPING_APPEAR:
                effect = TypingAppearEffect(self.formatted_text_block.characters)
                self.active_text_effect = effect
                self.full_redraw()
            elif effect_name == TEXT_EFFECT_FADE_IN:
                effect = FadeInEffect(self.formatted_text_block.characters)
                self.active_text_effect = effect
                self.redraw_from_chunks()
            elif effect_name == TEXT_EFFECT_FADE_OUT:
                effect = FadeOutEffect(self.formatted_text_block.characters)
                self.active_text_effect = effect
                self.redraw_from_chunks()
            else:
                warnings.warn('Unsupported effect name: ' + effect_name + ' for text box')

    def rebuild_from_changed_theme_data(self):
        """
        Called by the UIManager to check the theming data and rebuild whatever needs rebuilding
        for this element when the theme data has changed.
        """
        has_any_changed = False

        # misc parameters
        shape_type = 'rectangle'
        shape_type_string = self.ui_theme.get_misc_data(self.object_ids, self.element_ids, 'shape')
        if shape_type_string is not None and shape_type_string in ['rectangle',
                                                                   'rounded_rectangle']:
            shape_type = shape_type_string
        if shape_type != self.shape_type:
            self.shape_type = shape_type
            has_any_changed = True

        if self._check_shape_theming_changed(defaults={'border_width': 1,
                                                       'shadow_width': 2,
                                                       'shape_corner_radius': 2}):
            has_any_changed = True

        padding = (5, 5)
        padding_str = self.ui_theme.get_misc_data(self.object_ids,
                                                  self.element_ids,
                                                  'padding')
        if padding_str is not None:
            try:
                padding = (int(padding_str.split(',')[0]), int(padding_str.split(',')[1]))
            except ValueError:
                padding = (5, 5)
        if padding != self.padding:
            self.padding = padding
            has_any_changed = True

        # colour parameters
        background_colour = self.ui_theme.get_colour_or_gradient(self.object_ids,
                                                                 self.element_ids,
                                                                 'dark_bg')
        if background_colour != self.background_colour:
            self.background_colour = background_colour
            has_any_changed = True

        border_colour = self.ui_theme.get_colour_or_gradient(self.object_ids,
                                                             self.element_ids,
                                                             'normal_border')
        if border_colour != self.border_colour:
            self.border_colour = border_colour
            has_any_changed = True

        if self._check_link_style_changed():
            has_any_changed = True

        if has_any_changed:
            self.rebuild()

    def _check_link_style_changed(self) -> bool:
        """
        Checks for any changes in hyper link related styling in the theme data.

        :return: True if changes detected.

        """
        has_any_changed = False
        # link styles
        link_normal_underline = True
        link_normal_underline_string = self.ui_theme.get_misc_data(self.object_ids,
                                                                   self.element_ids,
                                                                   'link_normal_underline')
        if link_normal_underline_string is not None:
            try:
                link_normal_underline = bool(int(link_normal_underline_string))
            except ValueError:
                link_normal_underline = True
        if link_normal_underline != self.link_normal_underline:
            self.link_normal_underline = link_normal_underline
        link_hover_underline = True
        link_hover_underline_string = self.ui_theme.get_misc_data(self.object_ids,
                                                                  self.element_ids,
                                                                  'link_hover_underline')
        if link_hover_underline_string is not None:
            try:
                link_hover_underline = bool(int(link_hover_underline_string))
            except ValueError:
                link_hover_underline = True
        if link_hover_underline != self.link_hover_underline:
            self.link_hover_underline = link_hover_underline
        link_normal_colour = self.ui_theme.get_colour_or_gradient(self.object_ids,
                                                                  self.element_ids,
                                                                  'link_text')
        if link_normal_colour != self.link_normal_colour:
            self.link_normal_colour = link_normal_colour
        link_hover_colour = self.ui_theme.get_colour_or_gradient(self.object_ids,
                                                                 self.element_ids,
                                                                 'link_hover')
        if link_hover_colour != self.link_hover_colour:
            self.link_hover_colour = link_hover_colour
        link_selected_colour = self.ui_theme.get_colour_or_gradient(self.object_ids,
                                                                    self.element_ids,
                                                                    'link_selected')
        if link_selected_colour != self.link_selected_colour:
            self.link_selected_colour = link_selected_colour
        link_style = {'link_text': self.link_normal_colour,
                      'link_hover': self.link_hover_colour,
                      'link_selected': self.link_selected_colour,
                      'link_normal_underline': self.link_normal_underline,
                      'link_hover_underline': self.link_hover_underline}
        if link_style != self.link_style:
            self.link_style = link_style
            has_any_changed = True
        return has_any_changed
class UIScrollingContainer(UIElement, IContainerLikeInterface):
    """
    A container like UI element that lets users scroll around a larger container of content with
    scroll bars.

    :param relative_rect: The size and relative position of the container. This will also be the
                          starting size of the scrolling area.
    :param manager: The UI manager for this element.
    :param starting_height: The starting layer height of this container above it's container.
                            Defaults to 1.
    :param container: The container this container is within. Defaults to None (which is the root
                      container for the UI)
    :param parent_element: A parent element for this container. Defaults to None, or the
                           container if you've set that.
    :param object_id: An object ID for this element.
    :param anchors: Layout anchors in a dictionary.
    :param visible: Whether the element is visible by default. Warning - container visibility
                    may override this.
    """
    def __init__(self,
                 relative_rect: pygame.Rect,
                 manager: IUIManagerInterface,
                 *,
                 starting_height: int = 1,
                 container: Union[IContainerLikeInterface, None] = None,
                 parent_element: Union[UIElement, None] = None,
                 object_id: Union[ObjectID, str, None] = None,
                 anchors: Union[Dict[str, str], None] = None,
                 visible: int = 1):

        super().__init__(relative_rect,
                         manager,
                         container,
                         starting_height=starting_height,
                         layer_thickness=2,
                         anchors=anchors,
                         visible=visible)

        self._create_valid_ids(container=container,
                               parent_element=parent_element,
                               object_id=object_id,
                               element_id='scrolling_container')

        # self.parent_element = parent_element
        self.scroll_bar_width = 0
        self.scroll_bar_height = 0

        self.need_to_sort_out_scrollbars = False
        self.vert_scroll_bar = None  # type: Union[UIVerticalScrollBar, None]
        self.horiz_scroll_bar = None  # type: Union[UIHorizontalScrollBar, None]

        self.set_image(self.ui_manager.get_universal_empty_surface())

        # this contains the scroll bars and the 'view' container
        self._root_container = UIContainer(relative_rect=relative_rect,
                                           manager=manager,
                                           starting_height=starting_height,
                                           container=container,
                                           parent_element=parent_element,
                                           object_id=ObjectID(object_id='#root_container',
                                                              class_id=None),
                                           anchors=anchors,
                                           visible=self.visible)

        # This container is the view on to the scrollable container it's size is determined by
        # the size of the root container and whether there are any scroll bars or not.
        view_rect = pygame.Rect(0, 0, relative_rect.width, relative_rect.height)
        self._view_container = UIContainer(relative_rect=view_rect,
                                           manager=manager,
                                           starting_height=0,
                                           container=self._root_container,
                                           parent_element=parent_element,
                                           object_id=ObjectID(object_id='#view_container',
                                                              class_id=None),
                                           anchors={'left': 'left',
                                                    'right': 'right',
                                                    'top': 'top',
                                                    'bottom': 'bottom'})

        # This container is what we actually put other stuff in.
        # It is aligned to the top left corner but that isn't that important for a container that
        # can be much larger than it's view
        scrollable_rect = pygame.Rect(0, 0, relative_rect.width, relative_rect.height)
        self.scrollable_container = UIContainer(relative_rect=scrollable_rect,
                                                manager=manager,
                                                starting_height=0,
                                                container=self._view_container,
                                                parent_element=parent_element,
                                                object_id=ObjectID(
                                                    object_id='#scrollable_container',
                                                    class_id=None),
                                                anchors={'left': 'left',
                                                         'right': 'left',
                                                         'top': 'top',
                                                         'bottom': 'top'})

        self.scrolling_height = 0
        self.scrolling_width = 0

        self.scrolling_bottom = 0
        self.scrolling_right = 0
        self._calculate_scrolling_dimensions()

    def get_container(self) -> IUIContainerInterface:
        """
        Gets the scrollable container area (the one that moves around with the scrollbars)
        from this container-like UI element.

        :return: the scrolling container.
        """
        return self.scrollable_container

    def kill(self):
        """
        Overrides the basic kill() method of a pygame sprite so that we also kill all the UI
        elements in this panel.

        """
        self._root_container.kill()
        super().kill()

    def set_position(self, position: Union[pygame.math.Vector2,
                                           Tuple[int, int],
                                           Tuple[float, float]]):
        """
        Method to directly set the absolute screen rect position of an element.

        :param position: The new position to set.

        """

        super().set_position(position)
        self._root_container.set_dimensions(position)

    def set_relative_position(self, position: Union[pygame.math.Vector2,
                                                    Tuple[int, int],
                                                    Tuple[float, float]]):
        """
        Method to directly set the relative rect position of an element.

        :param position: The new position to set.

        """
        super().set_relative_position(position)
        self._root_container.set_relative_position(position)

    def set_dimensions(self, dimensions: Union[pygame.math.Vector2,
                                               Tuple[int, int],
                                               Tuple[float, float]]):
        """
        Method to directly set the dimensions of an element.

        NOTE: Using this on elements inside containers with non-default anchoring arrangements
        may make a mess of them.

        :param dimensions: The new dimensions to set.

        """
        super().set_dimensions(dimensions)
        self._root_container.set_dimensions(dimensions)

        self._calculate_scrolling_dimensions()
        self._sort_out_element_container_scroll_bars()

    def set_scrollable_area_dimensions(self, dimensions: Union[pygame.math.Vector2,
                                                               Tuple[int, int],
                                                               Tuple[float, float]]):
        """
        Set the size of the scrollable area container. It starts the same size as the view
        container but often you want to expand it, or why have a scrollable container?

        :param dimensions: The new dimensions.
        """
        self.scrollable_container.set_dimensions(dimensions)

        self._calculate_scrolling_dimensions()
        self._sort_out_element_container_scroll_bars()

    def update(self, time_delta: float):
        """
        Updates the scrolling container's position based upon the scroll bars and updates the
        scrollbar's visible percentage as well if that has changed.

        :param time_delta: The time passed between frames, measured in seconds.

        """
        super().update(time_delta)

        if (self.vert_scroll_bar is not None and
                self.vert_scroll_bar.check_has_moved_recently()):

            self._calculate_scrolling_dimensions()
            vis_percent = self._view_container.rect.height / self.scrolling_height
            if self.vert_scroll_bar.start_percentage <= 0.5:
                start_height = int(self.vert_scroll_bar.start_percentage *
                                   self.scrolling_height)
            else:
                button_percent_height = (self.vert_scroll_bar.sliding_button.rect.height /
                                         self.vert_scroll_bar.scrollable_height)
                button_bottom_percent = (self.vert_scroll_bar.start_percentage +
                                         button_percent_height)
                start_height = (int(button_bottom_percent * self.scrolling_height) -
                                self._view_container.rect.height)
            if vis_percent < 1.0:
                self.vert_scroll_bar.set_visible_percentage(vis_percent)
            else:
                self._remove_vert_scrollbar()

            if self.scrolling_bottom < self._view_container.rect.bottom:
                start_height = min(start_height, self._view_container.rect.height)

            new_pos = (self.scrollable_container.relative_rect.x,
                       -start_height)
            self.scrollable_container.set_relative_position(new_pos)

        if (self.horiz_scroll_bar is not None and
                self.horiz_scroll_bar.check_has_moved_recently()):

            self._calculate_scrolling_dimensions()
            vis_percent = self._view_container.rect.width / self.scrolling_width
            if self.horiz_scroll_bar.start_percentage <= 0.5:
                start_width = int(self.horiz_scroll_bar.start_percentage *
                                  self.scrolling_width)
            else:
                button_percent_width = (self.horiz_scroll_bar.sliding_button.rect.width /
                                        self.horiz_scroll_bar.scrollable_width)
                button_right_percent = (self.horiz_scroll_bar.start_percentage +
                                        button_percent_width)
                start_width = (int(button_right_percent * self.scrolling_width) -
                               self._view_container.rect.width)
            if vis_percent < 1.0:
                self.horiz_scroll_bar.set_visible_percentage(vis_percent)
            else:
                self._remove_horiz_scrollbar()

            if self.scrolling_right < self._view_container.rect.right:
                start_width = min(start_width, self._view_container.rect.width)
            new_pos = (-start_width,
                       self.scrollable_container.relative_rect.y)
            self.scrollable_container.set_relative_position(new_pos)

    def _calculate_scrolling_dimensions(self):
        """
        Calculate all the variables we need to scroll the container correctly.

        This is a bit of a fiddly process since we can resize our viewing area, the scrollable
        area and we generally don't want to yank the area you are looking at too much either.

        Plus, the scrollbars only have somewhat limited accuracy so need clamping...
        """
        scrolling_top = min(self.scrollable_container.rect.top,
                            self._view_container.rect.top)

        scrolling_left = min(self.scrollable_container.rect.left,
                             self._view_container.rect.left)
        # used for clamping
        self.scrolling_bottom = max(self.scrollable_container.rect.bottom,
                                    self._view_container.rect.bottom)
        self.scrolling_right = max(self.scrollable_container.rect.right,
                                   self._view_container.rect.right)

        self.scrolling_height = self.scrolling_bottom - scrolling_top
        self.scrolling_width = self.scrolling_right - scrolling_left

    def _sort_out_element_container_scroll_bars(self):
        """
        This creates, re-sizes or removes the scrollbars after resizing, but not after the scroll
        bar has been moved. Instead it tries to keep the scrollbars in the same approximate position
        they were in before resizing
        """
        self._check_scroll_bars_and_adjust()
        need_horiz_scroll_bar, need_vert_scroll_bar = self._check_scroll_bars_and_adjust()

        if need_vert_scroll_bar:
            vis_percent = self._view_container.rect.height / self.scrolling_height
            if self.vert_scroll_bar is None:
                self.scroll_bar_width = 20
                scroll_bar_rect = pygame.Rect(-self.scroll_bar_width,
                                              0,
                                              self.scroll_bar_width,
                                              self._view_container.rect.height)
                self.vert_scroll_bar = UIVerticalScrollBar(relative_rect=scroll_bar_rect,
                                                           visible_percentage=vis_percent,
                                                           manager=self.ui_manager,
                                                           container=self._root_container,
                                                           parent_element=self,
                                                           anchors={'left': 'right',
                                                                    'right': 'right',
                                                                    'top': 'top',
                                                                    'bottom': 'bottom'})
            else:
                start_percent = ((self._view_container.rect.top -
                                  self.scrollable_container.rect.top)
                                 / self.scrolling_height)
                self.vert_scroll_bar.start_percentage = start_percent
                self.vert_scroll_bar.set_visible_percentage(vis_percent)
                self.vert_scroll_bar.set_dimensions((self.scroll_bar_width,
                                                     self._view_container.rect.height))
        else:
            self._remove_vert_scrollbar()

        if need_horiz_scroll_bar:
            vis_percent = self._view_container.rect.width / self.scrolling_width
            if self.horiz_scroll_bar is None:
                self.scroll_bar_height = 20
                scroll_bar_rect = pygame.Rect(0,
                                              -self.scroll_bar_height,
                                              self._view_container.rect.width,
                                              self.scroll_bar_height)
                self.horiz_scroll_bar = UIHorizontalScrollBar(relative_rect=scroll_bar_rect,
                                                              visible_percentage=vis_percent,
                                                              manager=self.ui_manager,
                                                              container=self._root_container,
                                                              parent_element=self,
                                                              anchors={'left': 'left',
                                                                       'right': 'right',
                                                                       'top': 'bottom',
                                                                       'bottom': 'bottom'})
            else:
                start_percent = ((self._view_container.rect.left -
                                  self.scrollable_container.rect.left)
                                 / self.scrolling_width)
                self.horiz_scroll_bar.start_percentage = start_percent
                self.horiz_scroll_bar.set_visible_percentage(vis_percent)
                self.horiz_scroll_bar.set_dimensions((self._view_container.rect.width,
                                                      self.scroll_bar_height))
        else:
            self._remove_horiz_scrollbar()

    def _check_scroll_bars_and_adjust(self):
        """
        Check if we need a horizontal or vertical scrollbar and adjust the containers if we do.

        Adjusting the containers for a scrollbar, may mean we now need a scrollbar in the other
        dimension so we need to call this twice.
        """
        self.scroll_bar_width = 0
        self.scroll_bar_height = 0
        need_horiz_scroll_bar = False
        need_vert_scroll_bar = False
        if (self.scrolling_height > self._view_container.rect.height or
                self.scrollable_container.relative_rect.top != 0):
            need_vert_scroll_bar = True
            self.scroll_bar_width = 20
        if (self.scrolling_width > self._view_container.rect.width or
                self.scrollable_container.relative_rect.left != 0):
            need_horiz_scroll_bar = True
            self.scroll_bar_height = 20
        if need_vert_scroll_bar or need_horiz_scroll_bar:
            new_width = (self._root_container.rect.width - self.scroll_bar_width)
            new_height = (self._root_container.rect.height - self.scroll_bar_height)
            new_dimensions = (new_width, new_height)
            self._view_container.set_dimensions(new_dimensions)
        self._calculate_scrolling_dimensions()
        return need_horiz_scroll_bar, need_vert_scroll_bar

    def _remove_vert_scrollbar(self):
        """
        Get rid of the vertical scroll bar and resize the containers appropriately.

        """
        if self.vert_scroll_bar is not None:
            self.vert_scroll_bar.kill()
            self.vert_scroll_bar = None
            self.scroll_bar_width = 0
            new_width = (self._root_container.rect.width - self.scroll_bar_width)

            old_height = self._view_container.rect.height
            new_dimensions = (new_width, old_height)
            self._view_container.set_dimensions(new_dimensions)
            self._calculate_scrolling_dimensions()
            if self.horiz_scroll_bar is not None:
                self.horiz_scroll_bar.set_dimensions((self._view_container.rect.width,
                                                      self.scroll_bar_height))

    def _remove_horiz_scrollbar(self):
        """
        Get rid of the horiz scroll bar and resize the containers appropriately.

        """
        if self.horiz_scroll_bar is not None:
            self.horiz_scroll_bar.kill()
            self.horiz_scroll_bar = None
            self.scroll_bar_height = 0
            new_height = (self._root_container.rect.height - self.scroll_bar_height)

            old_width = self._view_container.rect.width
            new_dimensions = (old_width, new_height)
            self._view_container.set_dimensions(new_dimensions)
            self._calculate_scrolling_dimensions()
            if self.vert_scroll_bar is not None:
                self.vert_scroll_bar.set_dimensions((self.scroll_bar_width,
                                                     self._view_container.rect.height))

    def disable(self):
        """
        Disables all elements in the container so they are no longer interactive.
        """
        if self.is_enabled:
            self.is_enabled = False
            self._root_container.disable()

    def enable(self):
        """
        Enables all elements in the container so they are interactive again.
        """
        if not self.is_enabled:
            self.is_enabled = True
            self._root_container.enable()

    def show(self):
        """
        In addition to the base UIElement.show() - call show() of owned container - _root_container.
        All other subelements (view_container, scrollbars) are children of _root_container, so
        it's visibility will propagate to them - there is no need to call their show() methods
        separately.
        """
        super().show()
        self._root_container.show()

    def hide(self):
        """
        In addition to the base UIElement.hide() - call hide() of owned container - _root_container.
        All other subelements (view_container, scrollbars) are children of _root_container, so
        it's visibility will propagate to them - there is no need to call their hide() methods
        separately.
        """
        self._root_container.hide()
        super().hide()
예제 #26
0
    def rebuild(self):
        """
        Rebuild whatever needs building.

        """
        if self.scroll_bar is not None:
            self.scroll_bar.kill()

        # The text_wrap_area is the part of the text box that we try to keep the text inside
        # of so that none  of it overlaps. Essentially we start with the containing box,
        # subtract the border, then subtract the padding, then if necessary subtract the width
        # of the scroll bar
        self.rounded_corner_offset = int(self.shape_corner_radius -
                                         (math.sin(math.pi / 4) *
                                          self.shape_corner_radius))
        self.text_wrap_rect = pygame.Rect(
            (self.rect[0] + self.padding[0] + self.border_width +
             self.shadow_width + self.rounded_corner_offset),
            (self.rect[1] + self.padding[1] + self.border_width +
             self.shadow_width + self.rounded_corner_offset),
            max(1, (self.rect[2] - (self.padding[0] * 2) -
                    (self.border_width * 2) - (self.shadow_width * 2) -
                    (2 * self.rounded_corner_offset))),
            max(1, (self.rect[3] - (self.padding[1] * 2) -
                    (self.border_width * 2) - (self.shadow_width * 2) -
                    (2 * self.rounded_corner_offset))))
        if self.wrap_to_height or self.rect[3] == -1:
            self.text_wrap_rect.height = -1
        if self.rect[2] == -1:
            self.text_wrap_rect.width = -1

        drawable_area_size = (self.text_wrap_rect[2], self.text_wrap_rect[3])

        # This gives us the height of the text at the 'width' of the text_wrap_area
        self.parse_html_into_style_data()
        if self.text_box_layout is not None:
            if self.wrap_to_height or self.rect[3] == -1 or self.rect[2] == -1:
                final_text_area_size = self.text_box_layout.layout_rect.size
                new_dimensions = (
                    (final_text_area_size[0] + (self.padding[0] * 2) +
                     (self.border_width * 2) + (self.shadow_width * 2) +
                     (2 * self.rounded_corner_offset)),
                    (final_text_area_size[1] + (self.padding[1] * 2) +
                     (self.border_width * 2) + (self.shadow_width * 2) +
                     (2 * self.rounded_corner_offset)))
                self.set_dimensions(new_dimensions)

                # need to regen this because it was dynamically generated
                drawable_area_size = (max(
                    1, (self.rect[2] - (self.padding[0] * 2) -
                        (self.border_width * 2) - (self.shadow_width * 2) -
                        (2 * self.rounded_corner_offset))),
                                      max(1,
                                          (self.rect[3] -
                                           (self.padding[1] * 2) -
                                           (self.border_width * 2) -
                                           (self.shadow_width * 2) -
                                           (2 * self.rounded_corner_offset))))

            elif self.text_box_layout.layout_rect.height > self.text_wrap_rect[
                    3]:
                # We need a scrollbar because our text is longer than the space we
                # have to display it. This also means we need to parse the text again.
                text_rect_width = (self.rect[2] - (self.padding[0] * 2) -
                                   (self.border_width * 2) -
                                   (self.shadow_width * 2) -
                                   self.rounded_corner_offset -
                                   self.scroll_bar_width)
                self.text_wrap_rect = pygame.Rect(
                    (self.rect[0] + self.padding[0] + self.border_width +
                     self.shadow_width + self.rounded_corner_offset),
                    (self.rect[1] + self.padding[1] + self.border_width +
                     self.shadow_width + self.rounded_corner_offset),
                    max(1, text_rect_width),
                    max(1, (self.rect[3] - (self.padding[1] * 2) -
                            (self.border_width * 2) - (self.shadow_width * 2) -
                            (2 * self.rounded_corner_offset))))
                self.parse_html_into_style_data()
                percentage_visible = (self.text_wrap_rect[3] /
                                      self.text_box_layout.layout_rect.height)
                scroll_bar_position = (self.relative_rect.right -
                                       self.border_width - self.shadow_width -
                                       self.scroll_bar_width,
                                       self.relative_rect.top +
                                       self.border_width + self.shadow_width)

                scroll_bar_rect = pygame.Rect(
                    scroll_bar_position,
                    (self.scroll_bar_width, self.rect.height -
                     (2 * self.border_width) - (2 * self.shadow_width)))
                self.scroll_bar = UIVerticalScrollBar(scroll_bar_rect,
                                                      percentage_visible,
                                                      self.ui_manager,
                                                      self.ui_container,
                                                      parent_element=self,
                                                      visible=self.visible)
                self.join_focus_sets(self.scroll_bar)
            else:
                new_dimensions = (self.rect[2], self.rect[3])
                self.set_dimensions(new_dimensions)

        theming_parameters = {
            'normal_bg': self.background_colour,
            'normal_border': self.border_colour,
            'border_width': self.border_width,
            'shadow_width': self.shadow_width,
            'shape_corner_radius': self.shape_corner_radius
        }

        if self.shape == 'rectangle':
            self.drawable_shape = RectDrawableShape(self.rect,
                                                    theming_parameters,
                                                    ['normal'],
                                                    self.ui_manager)
        elif self.shape == 'rounded_rectangle':
            self.drawable_shape = RoundedRectangleShape(
                self.rect, theming_parameters, ['normal'], self.ui_manager)

        self.background_surf = self.drawable_shape.get_fresh_surface()

        if self.scroll_bar is not None:
            height_adjustment = int(self.scroll_bar.start_percentage *
                                    self.text_box_layout.layout_rect.height)
        else:
            height_adjustment = 0

        if self.rect.width <= 0 or self.rect.height <= 0:
            return

        drawable_area = pygame.Rect((0, height_adjustment), drawable_area_size)
        new_image = pygame.surface.Surface(self.rect.size,
                                           flags=pygame.SRCALPHA,
                                           depth=32)
        new_image.fill(pygame.Color(0, 0, 0, 0))
        basic_blit(new_image, self.background_surf, (0, 0))

        basic_blit(
            new_image, self.text_box_layout.finalised_surface,
            (self.padding[0] + self.border_width + self.shadow_width +
             self.rounded_corner_offset, self.padding[1] + self.border_width +
             self.shadow_width + self.rounded_corner_offset), drawable_area)

        self.set_image(new_image)
        self.link_hover_chunks = []
        self.text_box_layout.add_chunks_to_hover_group(self.link_hover_chunks)

        self.should_trigger_full_rebuild = False
        self.full_rebuild_countdown = self.time_until_full_rebuild_after_changing_size
예제 #27
0
class UITextBox(UIElement, IUITextOwnerInterface):
    """
    A Text Box element lets us display word-wrapped, formatted text. If the text to display is
    longer than the height of the box given then the element will automatically create a vertical
    scroll bar so that all the text can be seen.

    Formatting the text is done via a subset of HTML tags. Currently supported tags are:

    - <b></b> or <strong></strong> - to encase bold styled text.
    - <i></i>, <em></em> or <var></var> - to encase italic styled text.
    - <u></u> - to encase underlined text.
    - <a href='id'></a> - to encase 'link' text that can be clicked on to generate events with the
                          id given in href.
    - <body bgcolor='#FFFFFF'></body> - to change the background colour of encased text.
    - <br> - to start a new line.
    - <font face='verdana' color='#000000' size=3.5></font> - To set the font, colour and size of
                                                              encased text.

    More may be added in the future if needed or frequently requested.

    NOTE: if dimensions of the initial containing rect are set to -1 the text box will match the
    final dimension to whatever the text rendering produces. This lets us make dynamically sized
    text boxes depending on their contents.


    :param html_text: The HTML formatted text to display in this text box.
    :param relative_rect: The 'visible area' rectangle, positioned relative to it's container.
    :param manager: The UIManager that manages this element.
    :param wrap_to_height: False by default, if set to True the box will increase in height to
                           match the text within.
    :param layer_starting_height: Sets the height, above it's container, to start placing the text
                                  box at.
    :param container: The container that this element is within. If set to None will be the root
                      window's container.
    :param parent_element: The element this element 'belongs to' in the theming hierarchy.
    :param object_id: A custom defined ID for fine tuning of theming.
    :param anchors: A dictionary describing what this element's relative_rect is relative to.
    :param visible: Whether the element is visible by default. Warning - container visibility
                    may override this.
    """
    def __init__(self,
                 html_text: str,
                 relative_rect: pygame.Rect,
                 manager: IUIManagerInterface,
                 wrap_to_height: bool = False,
                 layer_starting_height: int = 1,
                 container: Union[IContainerLikeInterface, None] = None,
                 parent_element: UIElement = None,
                 object_id: Union[ObjectID, str, None] = None,
                 anchors: Dict[str, str] = None,
                 visible: int = 1):

        super().__init__(relative_rect,
                         manager,
                         container,
                         starting_height=layer_starting_height,
                         layer_thickness=2,
                         anchors=anchors,
                         visible=visible)

        self._create_valid_ids(container=container,
                               parent_element=parent_element,
                               object_id=object_id,
                               element_id='text_box')

        self.html_text = html_text
        self.appended_text = ""
        self.font_dict = self.ui_theme.get_font_dictionary()

        self.wrap_to_height = wrap_to_height
        self.link_hover_chunks = []  # container for any link chunks we have

        self.active_text_effect = None
        self.active_text_chunk_effects = []
        self.scroll_bar = None
        self.scroll_bar_width = 20

        self.border_width = None
        self.shadow_width = None
        self.padding = (5, 5)
        self.background_colour = None
        self.border_colour = None

        self.link_normal_colour = None
        self.link_hover_colour = None
        self.link_selected_colour = None
        self.link_normal_underline = False
        self.link_hover_underline = True
        self.link_style = None

        self.rounded_corner_offset = None
        self.text_box_layout = None  # type: Optional[TextBoxLayout]
        self.text_wrap_rect = None  # type: Optional[pygame.Rect]
        self.background_surf = None

        self.drawable_shape = None
        self.shape = 'rectangle'
        self.shape_corner_radius = None

        self.text_horiz_alignment = 'default'
        self.text_vert_alignment = 'default'
        self.text_horiz_alignment_padding = 0
        self.text_vert_alignment_padding = 0

        self.should_trigger_full_rebuild = True
        self.time_until_full_rebuild_after_changing_size = 0.2
        self.full_rebuild_countdown = self.time_until_full_rebuild_after_changing_size

        self.parser = None

        self.rebuild_from_changed_theme_data()

    def kill(self):
        """
        Overrides the standard sprite kill method to also kill any scroll bars belonging to this
        text box.
        """
        if self.scroll_bar is not None:
            self.scroll_bar.kill()
        super().kill()

    def rebuild(self):
        """
        Rebuild whatever needs building.

        """
        if self.scroll_bar is not None:
            self.scroll_bar.kill()

        # The text_wrap_area is the part of the text box that we try to keep the text inside
        # of so that none  of it overlaps. Essentially we start with the containing box,
        # subtract the border, then subtract the padding, then if necessary subtract the width
        # of the scroll bar
        self.rounded_corner_offset = int(self.shape_corner_radius -
                                         (math.sin(math.pi / 4) *
                                          self.shape_corner_radius))
        self.text_wrap_rect = pygame.Rect(
            (self.rect[0] + self.padding[0] + self.border_width +
             self.shadow_width + self.rounded_corner_offset),
            (self.rect[1] + self.padding[1] + self.border_width +
             self.shadow_width + self.rounded_corner_offset),
            max(1, (self.rect[2] - (self.padding[0] * 2) -
                    (self.border_width * 2) - (self.shadow_width * 2) -
                    (2 * self.rounded_corner_offset))),
            max(1, (self.rect[3] - (self.padding[1] * 2) -
                    (self.border_width * 2) - (self.shadow_width * 2) -
                    (2 * self.rounded_corner_offset))))
        if self.wrap_to_height or self.rect[3] == -1:
            self.text_wrap_rect.height = -1
        if self.rect[2] == -1:
            self.text_wrap_rect.width = -1

        drawable_area_size = (self.text_wrap_rect[2], self.text_wrap_rect[3])

        # This gives us the height of the text at the 'width' of the text_wrap_area
        self.parse_html_into_style_data()
        if self.text_box_layout is not None:
            if self.wrap_to_height or self.rect[3] == -1 or self.rect[2] == -1:
                final_text_area_size = self.text_box_layout.layout_rect.size
                new_dimensions = (
                    (final_text_area_size[0] + (self.padding[0] * 2) +
                     (self.border_width * 2) + (self.shadow_width * 2) +
                     (2 * self.rounded_corner_offset)),
                    (final_text_area_size[1] + (self.padding[1] * 2) +
                     (self.border_width * 2) + (self.shadow_width * 2) +
                     (2 * self.rounded_corner_offset)))
                self.set_dimensions(new_dimensions)

                # need to regen this because it was dynamically generated
                drawable_area_size = (max(
                    1, (self.rect[2] - (self.padding[0] * 2) -
                        (self.border_width * 2) - (self.shadow_width * 2) -
                        (2 * self.rounded_corner_offset))),
                                      max(1,
                                          (self.rect[3] -
                                           (self.padding[1] * 2) -
                                           (self.border_width * 2) -
                                           (self.shadow_width * 2) -
                                           (2 * self.rounded_corner_offset))))

            elif self.text_box_layout.layout_rect.height > self.text_wrap_rect[
                    3]:
                # We need a scrollbar because our text is longer than the space we
                # have to display it. This also means we need to parse the text again.
                text_rect_width = (self.rect[2] - (self.padding[0] * 2) -
                                   (self.border_width * 2) -
                                   (self.shadow_width * 2) -
                                   self.rounded_corner_offset -
                                   self.scroll_bar_width)
                self.text_wrap_rect = pygame.Rect(
                    (self.rect[0] + self.padding[0] + self.border_width +
                     self.shadow_width + self.rounded_corner_offset),
                    (self.rect[1] + self.padding[1] + self.border_width +
                     self.shadow_width + self.rounded_corner_offset),
                    max(1, text_rect_width),
                    max(1, (self.rect[3] - (self.padding[1] * 2) -
                            (self.border_width * 2) - (self.shadow_width * 2) -
                            (2 * self.rounded_corner_offset))))
                self.parse_html_into_style_data()
                percentage_visible = (self.text_wrap_rect[3] /
                                      self.text_box_layout.layout_rect.height)
                scroll_bar_position = (self.relative_rect.right -
                                       self.border_width - self.shadow_width -
                                       self.scroll_bar_width,
                                       self.relative_rect.top +
                                       self.border_width + self.shadow_width)

                scroll_bar_rect = pygame.Rect(
                    scroll_bar_position,
                    (self.scroll_bar_width, self.rect.height -
                     (2 * self.border_width) - (2 * self.shadow_width)))
                self.scroll_bar = UIVerticalScrollBar(scroll_bar_rect,
                                                      percentage_visible,
                                                      self.ui_manager,
                                                      self.ui_container,
                                                      parent_element=self,
                                                      visible=self.visible)
                self.join_focus_sets(self.scroll_bar)
            else:
                new_dimensions = (self.rect[2], self.rect[3])
                self.set_dimensions(new_dimensions)

        theming_parameters = {
            'normal_bg': self.background_colour,
            'normal_border': self.border_colour,
            'border_width': self.border_width,
            'shadow_width': self.shadow_width,
            'shape_corner_radius': self.shape_corner_radius
        }

        if self.shape == 'rectangle':
            self.drawable_shape = RectDrawableShape(self.rect,
                                                    theming_parameters,
                                                    ['normal'],
                                                    self.ui_manager)
        elif self.shape == 'rounded_rectangle':
            self.drawable_shape = RoundedRectangleShape(
                self.rect, theming_parameters, ['normal'], self.ui_manager)

        self.background_surf = self.drawable_shape.get_fresh_surface()

        if self.scroll_bar is not None:
            height_adjustment = int(self.scroll_bar.start_percentage *
                                    self.text_box_layout.layout_rect.height)
        else:
            height_adjustment = 0

        if self.rect.width <= 0 or self.rect.height <= 0:
            return

        drawable_area = pygame.Rect((0, height_adjustment), drawable_area_size)
        new_image = pygame.surface.Surface(self.rect.size,
                                           flags=pygame.SRCALPHA,
                                           depth=32)
        new_image.fill(pygame.Color(0, 0, 0, 0))
        basic_blit(new_image, self.background_surf, (0, 0))

        basic_blit(
            new_image, self.text_box_layout.finalised_surface,
            (self.padding[0] + self.border_width + self.shadow_width +
             self.rounded_corner_offset, self.padding[1] + self.border_width +
             self.shadow_width + self.rounded_corner_offset), drawable_area)

        self.set_image(new_image)
        self.link_hover_chunks = []
        self.text_box_layout.add_chunks_to_hover_group(self.link_hover_chunks)

        self.should_trigger_full_rebuild = False
        self.full_rebuild_countdown = self.time_until_full_rebuild_after_changing_size

    def _align_all_text_rows(self):
        """
        Aligns the text drawing position correctly according to our theming options.

        """
        # Horizontal alignment
        if self.text_horiz_alignment != 'default':
            if self.text_horiz_alignment == 'center':
                self.text_box_layout.horiz_center_all_rows()
            elif self.text_horiz_alignment == 'left':
                self.text_box_layout.align_left_all_rows(
                    self.text_horiz_alignment_padding)
            else:
                self.text_box_layout.align_right_all_rows(
                    self.text_horiz_alignment_padding)

        # Vertical alignment
        if self.text_vert_alignment != 'default':
            if self.text_vert_alignment == 'center':
                self.text_box_layout.vert_center_all_rows()
            elif self.text_vert_alignment == 'top':
                self.text_box_layout.vert_align_top_all_rows(
                    self.text_vert_alignment_padding)
            else:
                self.text_box_layout.vert_align_bottom_all_rows(
                    self.text_vert_alignment_padding)

    def update(self, time_delta: float):
        """
        Called once every update loop of the UI Manager. Used to react to scroll bar movement
        (if there is one), update the text effect (if there is one) and check if we are hovering
        over any text links (if there are any).

        :param time_delta: The time in seconds between calls to update. Useful for timing things.

        """
        super().update(time_delta)
        if not self.alive():
            return
        if self.scroll_bar is not None and self.scroll_bar.check_has_moved_recently(
        ):
            height_adjustment = int(self.scroll_bar.start_percentage *
                                    self.text_box_layout.layout_rect.height)

            drawable_area_size = (max(
                1, (self.rect[2] - (self.padding[0] * 2) -
                    (self.border_width * 2) - (self.shadow_width * 2) -
                    (2 * self.rounded_corner_offset))),
                                  max(1,
                                      (self.rect[3] - (self.padding[1] * 2) -
                                       (self.border_width * 2) -
                                       (self.shadow_width * 2) -
                                       (2 * self.rounded_corner_offset))))
            drawable_area = pygame.Rect((0, height_adjustment),
                                        drawable_area_size)

            if self.rect.width <= 0 or self.rect.height <= 0:
                return

            new_image = pygame.surface.Surface(self.rect.size,
                                               flags=pygame.SRCALPHA,
                                               depth=32)
            new_image.fill(pygame.Color(0, 0, 0, 0))
            basic_blit(new_image, self.background_surf, (0, 0))
            basic_blit(new_image, self.text_box_layout.finalised_surface,
                       (self.padding[0] + self.border_width +
                        self.shadow_width + self.rounded_corner_offset,
                        self.padding[1] + self.border_width +
                        self.shadow_width + self.rounded_corner_offset),
                       drawable_area)
            self.set_image(new_image)

        mouse_x, mouse_y = self.ui_manager.get_mouse_position()
        should_redraw_from_layout = False

        if self.scroll_bar is not None:
            height_adjustment = (self.scroll_bar.start_percentage *
                                 self.text_box_layout.layout_rect.height)
        else:
            height_adjustment = 0
        base_x = int(self.rect[0] + self.padding[0] + self.border_width +
                     self.shadow_width + self.rounded_corner_offset)
        base_y = int(self.rect[1] + self.padding[1] + self.border_width +
                     self.shadow_width + self.rounded_corner_offset -
                     height_adjustment)

        for chunk in self.link_hover_chunks:
            hovered_currently = False

            hover_rect = pygame.Rect((base_x + chunk.x, base_y + chunk.y),
                                     chunk.size)
            if hover_rect.collidepoint(mouse_x,
                                       mouse_y) and self.rect.collidepoint(
                                           mouse_x, mouse_y):
                hovered_currently = True
            if chunk.is_hovered and not hovered_currently:
                chunk.on_unhovered()
                should_redraw_from_layout = True
            elif hovered_currently and not chunk.is_hovered:
                chunk.on_hovered()
                should_redraw_from_layout = True

        if should_redraw_from_layout:
            self.redraw_from_text_block()

        self.update_text_effect(time_delta)

        if self.should_trigger_full_rebuild and self.full_rebuild_countdown <= 0.0:
            self.rebuild()

        if self.full_rebuild_countdown > 0.0:
            self.full_rebuild_countdown -= time_delta

    def on_fresh_drawable_shape_ready(self):
        """
        Called by an element's drawable shape when it has a new image surface ready for use,
        normally after a rebuilding/redrawing of some kind.
        """
        self.background_surf = self.drawable_shape.get_fresh_surface()
        self.redraw_from_text_block()

    def set_relative_position(self, position: Union[pygame.math.Vector2,
                                                    Tuple[int, int],
                                                    Tuple[float, float]]):
        """
        Sets the relative screen position of this text box, updating it's subordinate scroll bar at
        the same time.

        :param position: The relative screen position to set.

        """
        super().set_relative_position(position)

        if self.scroll_bar is not None:
            scroll_bar_position = (self.relative_rect.right -
                                   self.border_width - self.shadow_width -
                                   self.scroll_bar_width,
                                   self.relative_rect.top + self.border_width +
                                   self.shadow_width)
            self.scroll_bar.set_relative_position(scroll_bar_position)

    def set_position(self, position: Union[pygame.math.Vector2,
                                           Tuple[int, int], Tuple[float,
                                                                  float]]):
        """
        Sets the absolute screen position of this text box, updating it's subordinate scroll bar
        at the same time.

        :param position: The absolute screen position to set.

        """
        super().set_position(position)

        if self.scroll_bar is not None:
            scroll_bar_position = (self.relative_rect.right -
                                   self.border_width - self.shadow_width -
                                   self.scroll_bar_width,
                                   self.relative_rect.top + self.border_width +
                                   self.shadow_width)
            self.scroll_bar.set_relative_position(scroll_bar_position)

    def set_dimensions(self, dimensions: Union[pygame.math.Vector2,
                                               Tuple[int, int], Tuple[float,
                                                                      float]]):
        """
        Method to directly set the dimensions of a text box.

        :param dimensions: The new dimensions to set.

        """
        self.relative_rect.width = int(dimensions[0])
        self.relative_rect.height = int(dimensions[1])
        self.rect.size = self.relative_rect.size

        if dimensions[0] >= 0 and dimensions[1] >= 0:
            if self.relative_right_margin is not None:
                self.relative_right_margin = self.ui_container.rect.right - self.rect.right

            if self.relative_bottom_margin is not None:
                self.relative_bottom_margin = self.ui_container.rect.bottom - self.rect.bottom

            self._update_container_clip()

            # Quick and dirty temporary scaling to cut down on number of
            # full rebuilds triggered when rapid scaling
            if self.image is not None:
                if (self.full_rebuild_countdown > 0.0
                        and (self.relative_rect.width > 0
                             and self.relative_rect.height > 0)):
                    new_image = pygame.surface.Surface(self.relative_rect.size,
                                                       flags=pygame.SRCALPHA,
                                                       depth=32)
                    new_image.fill(pygame.Color('#00000000'))
                    basic_blit(new_image, self.image, (0, 0))
                    self.set_image(new_image)

                    if self.scroll_bar is not None:
                        self.scroll_bar.set_dimensions(
                            (self.scroll_bar.relative_rect.width,
                             self.relative_rect.height -
                             (2 * self.border_width) -
                             (2 * self.shadow_width)))
                        scroll_bar_position = (self.relative_rect.right -
                                               self.border_width -
                                               self.shadow_width -
                                               self.scroll_bar_width,
                                               self.relative_rect.top +
                                               self.border_width +
                                               self.shadow_width)
                        self.scroll_bar.set_relative_position(
                            scroll_bar_position)

                self.should_trigger_full_rebuild = True
                self.full_rebuild_countdown = self.time_until_full_rebuild_after_changing_size

    def parse_html_into_style_data(self):
        """
        Parses HTML styled string text into a format more useful for styling pygame.freetype
        rendered text.
        """

        self.parser.feed(translate(self.html_text) + self.appended_text)

        self.text_box_layout = TextBoxLayout(
            self.parser.layout_rect_queue,
            pygame.Rect((0, 0),
                        (self.text_wrap_rect[2], self.text_wrap_rect[3])),
            pygame.Rect((0, 0),
                        (self.text_wrap_rect[2], self.text_wrap_rect[3])),
            line_spacing=1.25)
        self.parser.empty_layout_queue()
        if self.text_wrap_rect[3] == -1:
            self.text_box_layout.view_rect.height = self.text_box_layout.layout_rect.height

        self._align_all_text_rows()
        self.text_box_layout.finalise_to_new()

    def redraw_from_text_block(self):
        """
        Redraws the final parts of the text box element that don't include redrawing the actual
        text. Useful if we've just moved the position of the text (say, with a scroll bar)
        without actually changing the text itself.
        """
        if self.rect.width <= 0 or self.rect.height <= 0:
            return
        if self.scroll_bar is not None:
            height_adjustment = int(self.scroll_bar.start_percentage *
                                    self.text_box_layout.layout_rect.height)
            percentage_visible = (self.text_wrap_rect[3] /
                                  self.text_box_layout.layout_rect.height)
            self.scroll_bar.set_visible_percentage(percentage_visible)
        else:
            height_adjustment = 0
        drawable_area_size = (max(
            1,
            (self.rect[2] - (self.padding[0] * 2) - (self.border_width * 2) -
             (self.shadow_width * 2) - (2 * self.rounded_corner_offset))),
                              max(1, (self.rect[3] - (self.padding[1] * 2) -
                                      (self.border_width * 2) -
                                      (self.shadow_width * 2) -
                                      (2 * self.rounded_corner_offset))))
        drawable_area = pygame.Rect((0, height_adjustment), drawable_area_size)
        new_image = pygame.surface.Surface(self.rect.size,
                                           flags=pygame.SRCALPHA,
                                           depth=32)
        new_image.fill(pygame.Color(0, 0, 0, 0))
        basic_blit(new_image, self.background_surf, (0, 0))
        basic_blit(
            new_image, self.text_box_layout.finalised_surface,
            (self.padding[0] + self.border_width + self.shadow_width +
             self.rounded_corner_offset, self.padding[1] + self.border_width +
             self.shadow_width + self.rounded_corner_offset), drawable_area)
        self.set_image(new_image)

    def redraw_from_chunks(self):
        """
        Redraws from slightly earlier in the process than 'redraw_from_text_block'. Useful if we
        have redrawn individual chunks already (say, to change their style slightly after being
        hovered) and now want to update the text block with those changes without doing a
        full redraw.

        This won't work very well if redrawing a chunk changed it's dimensions.
        """
        self.text_box_layout.finalise_to_new()
        self.redraw_from_text_block()

    def full_redraw(self):
        """
        Trigger a full redraw of the entire text box. Useful if we have messed with the text
        chunks in a more fundamental fashion and need to reposition them (say, if some of them
        have gotten wider after being made bold).

        NOTE: This doesn't re-parse the text of our box. If you need to do that, just create a
        new text box.

        """
        self.text_box_layout.reprocess_layout_queue(
            pygame.Rect((0, 0),
                        (self.text_wrap_rect[2], self.text_wrap_rect[3])))
        self.text_box_layout.finalise_to_new()
        self.redraw_from_text_block()
        self.link_hover_chunks = []
        self.text_box_layout.add_chunks_to_hover_group(self.link_hover_chunks)

    def process_event(self, event: pygame.event.Event) -> bool:
        """
        Deals with input events. In this case we just handle clicks on any links in the text.

        :param event: A pygame event to check for a reaction to.

        :return: Returns True if we consumed this event.

        """
        consumed_event = False
        should_redraw_from_layout = False
        if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
            scaled_mouse_pos = self.ui_manager.calculate_scaled_mouse_position(
                event.pos)
            if self.hover_point(scaled_mouse_pos[0], scaled_mouse_pos[1]):
                consumed_event = True

                if self.is_enabled:
                    if self.scroll_bar is not None:
                        text_block_full_height = self.text_box_layout.layout_rect.height
                        height_adjustment = (self.scroll_bar.start_percentage *
                                             text_block_full_height)
                    else:
                        height_adjustment = 0
                    base_x = int(self.rect[0] + self.padding[0] +
                                 self.border_width + self.shadow_width +
                                 self.rounded_corner_offset)
                    base_y = int(self.rect[1] + self.padding[1] +
                                 self.border_width + self.shadow_width +
                                 self.rounded_corner_offset -
                                 height_adjustment)
                    for chunk in self.link_hover_chunks:

                        hover_rect = pygame.Rect(
                            (base_x + chunk.x, base_y + chunk.y), chunk.size)
                        if hover_rect.collidepoint(scaled_mouse_pos[0],
                                                   scaled_mouse_pos[1]):
                            consumed_event = True
                            if not chunk.is_active:
                                chunk.set_active()
                                should_redraw_from_layout = True

        if self.is_enabled and event.type == pygame.MOUSEBUTTONUP and event.button == 1:
            if self.scroll_bar is not None:
                height_adjustment = (self.scroll_bar.start_percentage *
                                     self.text_box_layout.layout_rect.height)
            else:
                height_adjustment = 0
            base_x = int(self.rect[0] + self.padding[0] + self.border_width +
                         self.shadow_width + self.rounded_corner_offset)
            base_y = int(self.rect[1] + self.padding[1] + self.border_width +
                         self.shadow_width + self.rounded_corner_offset -
                         height_adjustment)
            scaled_mouse_pos = self.ui_manager.calculate_scaled_mouse_position(
                event.pos)
            for chunk in self.link_hover_chunks:

                hover_rect = pygame.Rect((base_x + chunk.x, base_y + chunk.y),
                                         chunk.size)
                if (hover_rect.collidepoint(scaled_mouse_pos[0],
                                            scaled_mouse_pos[1])
                        and self.rect.collidepoint(scaled_mouse_pos[0],
                                                   scaled_mouse_pos[1])):
                    consumed_event = True
                    if chunk.is_active:

                        # old event - to be removed in 0.8.0
                        event_data = {
                            'user_type': OldType(UI_TEXT_BOX_LINK_CLICKED),
                            'link_target': chunk.href,
                            'ui_element': self,
                            'ui_object_id': self.most_specific_combined_id
                        }
                        pygame.event.post(
                            pygame.event.Event(pygame.USEREVENT, event_data))

                        # new event
                        event_data = {
                            'link_target': chunk.href,
                            'ui_element': self,
                            'ui_object_id': self.most_specific_combined_id
                        }
                        pygame.event.post(
                            pygame.event.Event(UI_TEXT_BOX_LINK_CLICKED,
                                               event_data))

                if chunk.is_active:
                    chunk.set_inactive()
                    should_redraw_from_layout = True

        if should_redraw_from_layout:
            self.redraw_from_text_block()

        return consumed_event

    def rebuild_from_changed_theme_data(self):
        """
        Called by the UIManager to check the theming data and rebuild whatever needs rebuilding
        for this element when the theme data has changed.
        """
        super().rebuild_from_changed_theme_data()
        has_any_changed = False

        # misc parameters
        if self._check_misc_theme_data_changed(
                attribute_name='shape',
                default_value='rectangle',
                casting_func=str,
                allowed_values=['rectangle', 'rounded_rectangle']):
            has_any_changed = True

        if self._check_shape_theming_changed(defaults={
                'border_width': 1,
                'shadow_width': 2,
                'shape_corner_radius': 2
        }):
            has_any_changed = True

        if self._check_misc_theme_data_changed(
                attribute_name='padding',
                default_value=(5, 5),
                casting_func=self.tuple_extract):
            has_any_changed = True

        # colour parameters
        background_colour = self.ui_theme.get_colour_or_gradient(
            'dark_bg', self.combined_element_ids)
        if background_colour != self.background_colour:
            self.background_colour = background_colour
            has_any_changed = True

        border_colour = self.ui_theme.get_colour_or_gradient(
            'normal_border', self.combined_element_ids)
        if border_colour != self.border_colour:
            self.border_colour = border_colour
            has_any_changed = True

        if self._check_text_alignment_theming():
            has_any_changed = True

        if self._check_link_style_changed():
            has_any_changed = True

        if has_any_changed:
            self._reparse_and_rebuild()

    def _reparse_and_rebuild(self):
        self.parser = HTMLParser(self.ui_theme,
                                 self.combined_element_ids,
                                 self.link_style,
                                 line_spacing=1.25)
        self.rebuild()

    def _check_text_alignment_theming(self) -> bool:
        """
        Checks for any changes in the theming data related to text alignment.

        :return: True if changes found.

        """
        has_any_changed = False

        if self._check_misc_theme_data_changed(
                attribute_name='text_horiz_alignment',
                default_value='left',
                casting_func=str):
            has_any_changed = True

        if self._check_misc_theme_data_changed(
                attribute_name='text_horiz_alignment_padding',
                default_value=0,
                casting_func=int):
            has_any_changed = True

        if self._check_misc_theme_data_changed(
                attribute_name='text_vert_alignment',
                default_value='top',
                casting_func=str):
            has_any_changed = True

        if self._check_misc_theme_data_changed(
                attribute_name='text_vert_alignment_padding',
                default_value=0,
                casting_func=int):
            has_any_changed = True

        return has_any_changed

    def _check_link_style_changed(self) -> bool:
        """
        Checks for any changes in hyper link related styling in the theme data.

        :return: True if changes detected.

        """
        has_any_changed = False

        if self._check_misc_theme_data_changed(
                attribute_name='link_normal_underline',
                default_value=True,
                casting_func=bool):
            has_any_changed = True

        if self._check_misc_theme_data_changed(
                attribute_name='link_hover_underline',
                default_value=True,
                casting_func=bool):
            has_any_changed = True

        link_normal_colour = self.ui_theme.get_colour_or_gradient(
            'link_text', self.combined_element_ids)
        if link_normal_colour != self.link_normal_colour:
            self.link_normal_colour = link_normal_colour
        link_hover_colour = self.ui_theme.get_colour_or_gradient(
            'link_hover', self.combined_element_ids)
        if link_hover_colour != self.link_hover_colour:
            self.link_hover_colour = link_hover_colour
        link_selected_colour = self.ui_theme.get_colour_or_gradient(
            'link_selected', self.combined_element_ids)
        if link_selected_colour != self.link_selected_colour:
            self.link_selected_colour = link_selected_colour
        link_style = {
            'link_text': self.link_normal_colour,
            'link_hover': self.link_hover_colour,
            'link_selected': self.link_selected_colour,
            'link_normal_underline': self.link_normal_underline,
            'link_hover_underline': self.link_hover_underline
        }
        if link_style != self.link_style:
            self.link_style = link_style
            has_any_changed = True
        return has_any_changed

    def disable(self):
        """
        Disable the text box. Basically just disables the scroll bar if one exists.
        """
        if self.is_enabled:
            self.is_enabled = False
            if self.scroll_bar:
                self.scroll_bar.disable()

    def enable(self):
        """
        Enable the text box. Renables the scroll bar if one exists.
        """
        if not self.is_enabled:
            self.is_enabled = True
            if self.scroll_bar:
                self.scroll_bar.enable()

    def show(self):
        """
        In addition to the base UIElement.show() - call show() of scroll_bar if it exists.
        """
        super().show()

        if self.scroll_bar is not None:
            self.scroll_bar.show()

    def hide(self):
        """
        In addition to the base UIElement.hide() - call hide() of scroll_bar if it exists.
        """
        super().hide()

        if self.scroll_bar is not None:
            self.scroll_bar.hide()

    def append_html_text(self, new_html_str: str):
        """
        Adds a string, that is parsed for any HTML tags that pygame_gui supports, onto the bottom
        of the text box's current contents.

        This is useful for making things like logs.

        :param new_html_str: The, potentially HTML tag, containing string of text to append.
        """
        self.appended_text += new_html_str
        self.parser.feed(new_html_str)
        self.text_box_layout.append_layout_rects(self.parser.layout_rect_queue)
        self.parser.empty_layout_queue()

        if (self.scroll_bar is None
                and (self.text_box_layout.layout_rect.height >
                     self.text_wrap_rect[3])):
            self.rebuild()
        else:
            if self.scroll_bar is not None:
                # set the scroll bar to the bottom
                percentage_visible = (self.text_wrap_rect[3] /
                                      self.text_box_layout.layout_rect.height)
                self.scroll_bar.start_percentage = 1.0 - percentage_visible
                self.scroll_bar.scroll_position = (
                    self.scroll_bar.start_percentage *
                    self.scroll_bar.scrollable_height)
            self.redraw_from_text_block()

    def on_locale_changed(self):
        self._reparse_and_rebuild()

    # -------------------------------------------------
    # The Text owner interface
    # -------------------------------------------------
    def set_text_alpha(self,
                       alpha: int,
                       sub_chunk: Optional[TextLineChunkFTFont] = None):
        if sub_chunk is None:
            self.text_box_layout.set_alpha(alpha)
        else:
            sub_chunk.set_alpha(alpha)

    def set_text_offset_pos(self,
                            offset: Tuple[int, int],
                            sub_chunk: Optional[TextLineChunkFTFont] = None):
        if sub_chunk is None:
            pass
        else:
            sub_chunk.set_offset_pos(offset)

    def set_text_rotation(self,
                          rotation: int,
                          sub_chunk: Optional[TextLineChunkFTFont] = None):
        if sub_chunk is None:
            pass
        else:
            sub_chunk.set_rotation(rotation)

    def set_text_scale(self,
                       scale: float,
                       sub_chunk: Optional[TextLineChunkFTFont] = None):
        if sub_chunk is None:
            pass
        else:
            sub_chunk.set_scale(scale)

    def clear_text_surface(self,
                           sub_chunk: Optional[TextLineChunkFTFont] = None):
        if sub_chunk is None:
            self.text_box_layout.clear_final_surface()
        else:
            sub_chunk.clear()

    def get_text_letter_count(self,
                              sub_chunk: Optional[TextLineChunkFTFont] = None
                              ) -> int:
        if sub_chunk is None:
            return self.text_box_layout.letter_count
        else:
            return sub_chunk.letter_count

    def update_text_end_position(
            self,
            end_pos: int,
            sub_chunk: Optional[TextLineChunkFTFont] = None):
        if sub_chunk is None:
            self.text_box_layout.update_text_with_new_text_end_pos(end_pos)
        else:
            sub_chunk.letter_end = end_pos
            sub_chunk.redraw()

    def set_active_effect(self,
                          effect_type: Optional[UITextEffectType] = None,
                          params: Optional[Dict[str, Any]] = None,
                          effect_tag: Optional[str] = None):
        if effect_tag is not None:
            redrew_all_chunks = False
            if self.active_text_effect is not None:
                # don't allow mixing of full text box effects and chunk effects
                self.clear_all_active_effects()
                self.active_text_effect = None
                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
            for row in self.text_box_layout.layout_rows:
                for chunk in row.items:
                    if isinstance(chunk, TextLineChunkFTFont):
                        if chunk.effect_id == effect_tag:
                            # need to clear off any old effects on this chunk too if we didn't
                            # already redraw everything
                            if not redrew_all_chunks:
                                self.clear_all_active_effects(chunk)
                            effect = 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)
                            else:
                                warnings.warn('Unsupported effect name: ' +
                                              str(effect_type) +
                                              ' for text chunk')
                            chunk.grab_pre_effect_surface()
                            self.active_text_chunk_effects.append({
                                'chunk':
                                chunk,
                                'effect':
                                effect
                            })
        else:
            if self.active_text_effect is not None or len(
                    self.active_text_chunk_effects) != 0:
                self.clear_all_active_effects()
            if effect_type is None:
                self.active_text_effect = None
            elif isinstance(effect_type, UITextEffectType):
                if effect_type == TEXT_EFFECT_TYPING_APPEAR:
                    self.active_text_effect = TypingAppearEffect(self, params)
                elif effect_type == TEXT_EFFECT_FADE_IN:
                    self.active_text_effect = FadeInEffect(self, params)
                elif effect_type == TEXT_EFFECT_FADE_OUT:
                    self.active_text_effect = FadeOutEffect(self, params)
                else:
                    warnings.warn('Unsupported effect name: ' +
                                  str(effect_type) + ' for whole text box')
            else:
                warnings.warn('Unsupported effect name: ' + str(effect_type) +
                              ' for whole text box')

    def stop_finished_effect(self,
                             sub_chunk: Optional[TextLineChunkFTFont] = None):
        if sub_chunk 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
            ]

    def clear_all_active_effects(
            self, sub_chunk: Optional[TextLineChunkFTFont] = None):
        if sub_chunk is None:
            self.active_text_effect = None
            self.text_box_layout.clear_effects()
            for effect_chunk in self.active_text_chunk_effects:
                effect_chunk['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()

            effect_chunks = []
            for affected_chunk in self.active_text_chunk_effects:
                effect_chunks.append(affected_chunk['chunk'])
            self.text_box_layout.redraw_other_chunks(effect_chunks)

            sub_chunk.redraw()

    def update_text_effect(self, time_delta: float):
        if self.active_text_effect is not None:
            self.active_text_effect.update(time_delta)
            # update can set effect to None
            if (self.active_text_effect is not None
                    and self.active_text_effect.has_text_changed()):
                self.active_text_effect.apply_effect()
                self.redraw_from_text_block()

        if len(self.active_text_chunk_effects) > 0:
            any_text_changed = False
            for affected_chunk in self.active_text_chunk_effects:
                affected_chunk['effect'].update(time_delta)
                if (affected_chunk['effect'] is not None
                        and affected_chunk['effect'].has_text_changed()):
                    any_text_changed = True
            if any_text_changed:
                effect_chunks = []
                for affected_chunk in self.active_text_chunk_effects:
                    chunk = affected_chunk['chunk']
                    chunk.clear(chunk.transform_effect_rect)
                    effect_chunks.append(chunk)

                self.text_box_layout.redraw_other_chunks(effect_chunks)

                for affected_chunk in self.active_text_chunk_effects:
                    affected_chunk['effect'].apply_effect()
                self.redraw_from_text_block()

    def get_object_id(self) -> str:
        return self.most_specific_combined_id

    def set_text(self, html_text: str):
        self.html_text = html_text
        self._reparse_and_rebuild()
    def _sort_out_element_container_scroll_bars(self):
        """
        This creates, re-sizes or removes the scrollbars after resizing, but not after the scroll
        bar has been moved. Instead it tries to keep the scrollbars in the same approximate position
        they were in before resizing
        """
        self._check_scroll_bars_and_adjust()
        need_horiz_scroll_bar, need_vert_scroll_bar = self._check_scroll_bars_and_adjust()

        if need_vert_scroll_bar:
            vis_percent = self._view_container.rect.height / self.scrolling_height
            if self.vert_scroll_bar is None:
                self.scroll_bar_width = 20
                scroll_bar_rect = pygame.Rect(-self.scroll_bar_width,
                                              0,
                                              self.scroll_bar_width,
                                              self._view_container.rect.height)
                self.vert_scroll_bar = UIVerticalScrollBar(relative_rect=scroll_bar_rect,
                                                           visible_percentage=vis_percent,
                                                           manager=self.ui_manager,
                                                           container=self._root_container,
                                                           parent_element=self,
                                                           anchors={'left': 'right',
                                                                    'right': 'right',
                                                                    'top': 'top',
                                                                    'bottom': 'bottom'})
            else:
                start_percent = ((self._view_container.rect.top -
                                  self.scrollable_container.rect.top)
                                 / self.scrolling_height)
                self.vert_scroll_bar.start_percentage = start_percent
                self.vert_scroll_bar.set_visible_percentage(vis_percent)
                self.vert_scroll_bar.set_dimensions((self.scroll_bar_width,
                                                     self._view_container.rect.height))
        else:
            self._remove_vert_scrollbar()

        if need_horiz_scroll_bar:
            vis_percent = self._view_container.rect.width / self.scrolling_width
            if self.horiz_scroll_bar is None:
                self.scroll_bar_height = 20
                scroll_bar_rect = pygame.Rect(0,
                                              -self.scroll_bar_height,
                                              self._view_container.rect.width,
                                              self.scroll_bar_height)
                self.horiz_scroll_bar = UIHorizontalScrollBar(relative_rect=scroll_bar_rect,
                                                              visible_percentage=vis_percent,
                                                              manager=self.ui_manager,
                                                              container=self._root_container,
                                                              parent_element=self,
                                                              anchors={'left': 'left',
                                                                       'right': 'right',
                                                                       'top': 'bottom',
                                                                       'bottom': 'bottom'})
            else:
                start_percent = ((self._view_container.rect.left -
                                  self.scrollable_container.rect.left)
                                 / self.scrolling_width)
                self.horiz_scroll_bar.start_percentage = start_percent
                self.horiz_scroll_bar.set_visible_percentage(vis_percent)
                self.horiz_scroll_bar.set_dimensions((self._view_container.rect.width,
                                                      self.scroll_bar_height))
        else:
            self._remove_horiz_scrollbar()