Example #1
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()
Example #2
0
class UITextEntryLine(UIElement):
    """
    A GUI element for text entry from a keyboard, on a single line. The element supports
    the standard copy and paste keyboard shortcuts CTRL+V, CTRL+C & CTRL+X as well as CTRL+A.

    There are methods that allow the entry element to restrict the characters that can be input
    into the text box

    The height of the text entry line element will be determined by the font used rather than
    the standard method for UIElements of just using the height of the input rectangle.

    :param relative_rect: A rectangle describing the position and width of the text entry element.
    :param manager: The UIManager that manages this element.
    :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.

    """

    _number_character_set = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']

    # excluding these characters won't ensure that user entered text is a valid filename but they
    # can help reduce the problems that input will leave you with.
    _forbidden_file_path_characters = [
        '<', '>', ':', '"', '/', '\\', '|', '?', '*', '\0', '.'
    ]

    def __init__(self,
                 relative_rect: pygame.Rect,
                 manager: IUIManagerInterface,
                 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_entry_line')
        super().__init__(relative_rect,
                         manager,
                         container,
                         starting_height=1,
                         layer_thickness=1,
                         element_ids=new_element_ids,
                         object_ids=new_object_ids,
                         anchors=anchors)
        self.focused = False

        self.text = ""

        # theme font
        self.font = None

        self.shadow_width = None
        self.border_width = None
        self.horiz_line_padding = None
        self.vert_line_padding = None
        self.text_surface = None
        self.cursor = None
        self.background_and_border = None
        self.text_image_rect = None
        self.text_image = None

        # colours from theme
        self.background_colour = None
        self.text_colour = None
        self.selected_text_colour = None
        self.selected_bg_colour = None
        self.border_colour = None
        self.padding = None

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

        # input timings - I expect nobody really wants to mess with these that much
        # ideally we could populate from the os settings but that sounds like a headache
        self.key_repeat = 0.5
        self.cursor_blink_delay_after_moving_acc = 0.0
        self.cursor_blink_delay_after_moving = 1.0
        self.blink_cursor_time_acc = 0.0
        self.blink_cursor_time = 0.4

        self.double_click_timer = self.ui_manager.get_double_click_time() + 1.0

        self.start_text_offset = 0
        self.edit_position = 0
        self.select_range = [0, 0]
        self.selection_in_progress = False

        self.cursor_on = False
        self.cursor_has_moved_recently = False
        self.should_redraw = False

        # restrictions on text input
        self.allowed_characters = None
        self.forbidden_characters = None
        self.length_limit = None

        self.rebuild_from_changed_theme_data()

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

        """
        self.set_dimensions((self.relative_rect.width, -1))

        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_and_border = self.drawable_shape.get_surface('normal')

        if self.text_image is None:
            self.text_image = pygame.Surface(self.text_image_rect.size,
                                             flags=pygame.SRCALPHA,
                                             depth=32)

        if isinstance(self.background_colour, ColourGradient):
            self.text_image.fill(pygame.Color("#FFFFFFFF"))
            self.background_colour.apply_gradient_to_surface(self.text_image)
        else:
            self.text_image.fill(self.background_colour)

        self.set_image(self.background_and_border.copy())

        line_height = self.font.size(' ')[1]
        self.cursor = pygame.Rect(
            (self.text_image_rect.x + self.horiz_line_padding -
             self.start_text_offset,
             self.text_image_rect.y + self.vert_line_padding),
            (1, line_height))

        # setup for drawing
        self.redraw()

    def set_text_length_limit(self, limit: int):
        """
        Allows a character limit to be set on the text entry element. By default there is no
        limit on the number of characters that can be entered.

        :param limit: The character limit as an integer.

        """
        self.length_limit = limit

    def get_text(self) -> str:
        """
        Gets the text in the entry line element.

        :return: A string.

        """
        return self.text

    def set_text(self, text: str):
        """
        Allows the text displayed in the text entry element to be set via code. Useful for
        setting an initial or existing value that is able to be edited.

        The string to set must be valid for the text entry element for this to work.

        :param text: The text string to set.

        """
        if self.validate_text_string(text):
            within_length_limit = True
            if self.length_limit is not None and len(text) > self.length_limit:
                within_length_limit = False
            if within_length_limit:
                self.text = text
                self.edit_position = 0
                self.should_redraw = True
            else:
                warnings.warn(
                    "Tried to set text string that is too long on text entry element"
                )
        else:
            warnings.warn(
                "Tried to set text string with invalid characters on text entry element"
            )

    def redraw(self):
        """
        Redraws the entire text entry element onto the underlying sprite image. Usually called
        when the displayed text has been edited or changed in some fashion.
        """
        if isinstance(self.background_colour, ColourGradient):
            self.text_image.fill(pygame.Color("#FFFFFFFF"))
            self.background_colour.apply_gradient_to_surface(self.text_image)
        else:
            self.text_image.fill(self.background_colour)

        if self.select_range[0] == self.select_range[1]:
            self._redraw_unselected_text()
        else:
            self._redraw_selected_text()

        text_clip_width = (self.rect.width - (self.horiz_line_padding * 2) -
                           (self.shape_corner_radius * 2) -
                           (self.border_width * 2) - (self.shadow_width * 2))

        width_to_edit_pos = self.font.size(self.text[:self.edit_position])[0]

        if self.start_text_offset > width_to_edit_pos:
            self.start_text_offset = width_to_edit_pos
        elif width_to_edit_pos > (self.start_text_offset + text_clip_width):
            self.start_text_offset = max(0,
                                         width_to_edit_pos - text_clip_width)
        elif width_to_edit_pos == 0:
            self.start_text_offset = 0

        if len(self.text) > 0:
            self.text_image.blit(
                self.text_surface,
                (self.horiz_line_padding, self.vert_line_padding),
                pygame.Rect(
                    (self.start_text_offset, 0),
                    (text_clip_width,
                     (self.rect.height - (self.vert_line_padding * 2) -
                      (self.border_width * 2) - (self.shadow_width * 2)))))

        self.redraw_cursor()

    def _redraw_unselected_text(self):
        """
        Redraw text where none has been selected by a user.
        """
        if isinstance(self.text_colour, ColourGradient):
            self.text_surface = self.font.render(self.text, True,
                                                 pygame.Color('#FFFFFFFF'))
            self.text_colour.apply_gradient_to_surface(self.text_surface)

        else:
            self.text_surface = self.font.render(self.text, True,
                                                 self.text_colour)

    def _redraw_selected_text(self):
        """
        Redraw text where some has been selected by a user.
        """
        low_end = min(self.select_range[0], self.select_range[1])
        high_end = max(self.select_range[0], self.select_range[1])
        pre_select_area_text = self.text[:low_end]
        select_area_text = self.text[low_end:high_end]
        post_select_area_text = self.text[high_end:]
        pre_select_area_surface = None
        post_select_area_surface = None

        overall_size = self.font.size(self.text)
        advances = [
            letter_metrics[4]
            for letter_metrics in self.font.metrics(self.text)
        ]
        pre_select_width = sum(advances[:low_end])
        select_area_width = sum(advances[low_end:high_end])

        if len(pre_select_area_text) > 0:
            pre_select_area_surface = self._draw_text_with_grad_or_col(
                pre_select_area_text, self.text_colour)

        if isinstance(self.selected_bg_colour, ColourGradient):
            select_area_surface = pygame.Surface(
                (select_area_width, overall_size[1]),
                flags=pygame.SRCALPHA,
                depth=32)
            select_area_surface.fill(pygame.Color('#FFFFFFFF'))
            self.selected_bg_colour.apply_gradient_to_surface(
                select_area_surface)

            alpha_text = self._draw_text_with_grad_or_col(
                select_area_text, self.selected_text_colour)

            select_area_surface.blit(alpha_text, (0, 0))
        else:
            if isinstance(self.selected_text_colour, ColourGradient):
                select_area_surface = pygame.Surface(
                    (select_area_width, overall_size[1]),
                    flags=pygame.SRCALPHA,
                    depth=32)
                select_area_surface.fill(self.selected_bg_colour)

                alpha_text = self.font.render(select_area_text, True,
                                              pygame.Color('#FFFFFFFF'))
                self.selected_text_colour.apply_gradient_to_surface(alpha_text)
                select_area_surface.blit(alpha_text, (0, 0))

            else:
                select_area_surface = self.font.render(
                    select_area_text, True, self.selected_text_colour,
                    self.selected_bg_colour)
        if len(post_select_area_text) > 0:
            post_select_area_surface = self._draw_text_with_grad_or_col(
                post_select_area_text, self.text_colour)

        self.text_surface = pygame.Surface(overall_size,
                                           flags=pygame.SRCALPHA,
                                           depth=32)

        if isinstance(self.background_colour, ColourGradient):
            self.text_image.fill(pygame.Color("#FFFFFFFF"))
            self.background_colour.apply_gradient_to_surface(self.text_image)
        else:
            self.text_image.fill(self.background_colour)

        if pre_select_area_surface is not None:
            self.text_surface.blit(pre_select_area_surface, (0, 0))
        self.text_surface.blit(select_area_surface, (pre_select_width, 0))
        if post_select_area_surface is not None:
            self.text_surface.blit(post_select_area_surface,
                                   (pre_select_width + select_area_width, 0))

    def _draw_text_with_grad_or_col(
            self, text: str,
            col_or_grad: Union[ColourGradient,
                               pygame.Color]) -> pygame.Surface:
        """
        Draw text to a surface using either a colour or gradient.

        :param text: The text to render.
        :param col_or_grad: A colour or a colour gradient.

        :return: A surface with the text on.

        """
        if isinstance(col_or_grad, ColourGradient):
            text_surface = self.font.render(text, True,
                                            pygame.Color('#FFFFFFFF'))
            col_or_grad.apply_gradient_to_surface(text_surface)
        else:
            text_surface = self.font.render(text, True, col_or_grad)
        return text_surface

    def redraw_cursor(self):
        """
        Redraws only the blinking edit cursor. This allows us to blink the cursor on and off
        without spending time redrawing all the text.
        """
        new_image = self.background_and_border.copy()
        new_image.blit(self.text_image, self.text_image_rect)
        if self.cursor_on:
            cursor_len_str = self.text[:self.edit_position]
            cursor_size = self.font.size(cursor_len_str)
            self.cursor.x = (cursor_size[0] + self.text_image_rect.x +
                             self.horiz_line_padding - self.start_text_offset)

            if not isinstance(self.text_colour, ColourGradient):
                pygame.draw.rect(new_image, self.text_colour, self.cursor)
            else:
                cursor_surface = pygame.Surface(self.cursor.size)
                cursor_surface.fill(pygame.Color('#FFFFFFFF'))
                self.text_colour.apply_gradient_to_surface(cursor_surface)
                new_image.blit(cursor_surface, self.cursor)

        self.set_image(new_image)

    def update(self, time_delta: float):
        """
        Called every update loop of our UI Manager. Largely handles text drag selection and
        making sure our edit cursor blinks on and off.

        :param time_delta: The time in seconds between this update method call and the previous one.

        """
        super().update(time_delta)

        if not self.alive():
            return
        if self.double_click_timer < self.ui_manager.get_double_click_time():
            self.double_click_timer += time_delta
        if self.selection_in_progress:
            mouse_x, _ = self.ui_manager.get_mouse_position()
            select_end_pos = self.find_edit_position_from_pixel_pos(
                self.start_text_offset + mouse_x)
            new_range = [self.select_range[0], select_end_pos]

            if new_range[0] != self.select_range[0] or new_range[
                    1] != self.select_range[1]:
                self.select_range[0] = new_range[0]
                self.select_range[1] = new_range[1]
                self.edit_position = self.select_range[1]
                self.cursor_has_moved_recently = True

        if self.cursor_has_moved_recently:
            self.cursor_has_moved_recently = False
            self.cursor_blink_delay_after_moving_acc = 0.0
            self.cursor_on = True
            self.should_redraw = True

        if self.should_redraw:
            self.redraw()

        if self.cursor_blink_delay_after_moving_acc > self.cursor_blink_delay_after_moving:
            if self.blink_cursor_time_acc >= self.blink_cursor_time:
                self.blink_cursor_time_acc = 0.0
                if self.cursor_on:
                    self.cursor_on = False
                    self.redraw_cursor()
                elif self.focused:
                    self.cursor_on = True
                    self.redraw_cursor()
            else:
                self.blink_cursor_time_acc += time_delta
        else:
            self.cursor_blink_delay_after_moving_acc += time_delta

    def unfocus(self):
        """
        Called when this element is no longer the current focus.
        """
        self.focused = False
        pygame.key.set_repeat()
        self.select_range = [0, 0]
        self.edit_position = 0
        self.cursor_on = False
        self.redraw()

    def focus(self):
        """
        Called when we 'select focus' on this element. In this case it sets up the keyboard to
        repeat held key presses, useful for natural feeling keyboard input.
        """
        self.focused = True
        pygame.key.set_repeat(500, 25)
        self.redraw()

    def process_event(self, event: pygame.event.Event) -> bool:
        """
        Allows the text entry box to react to input events, which is it's primary function.
        The entry element reacts to various types of mouse clicks (double click selecting words,
        drag select), keyboard combos (CTRL+C, CTRL+V, CTRL+X, CTRL+A), individual editing keys
        (Backspace, Delete, Left & Right arrows) and other keys for inputting letters, symbols
        and numbers.

        :param event: The current event to consider reacting to.

        :return: Returns True if we've done something with the input event.

        """
        consumed_event = False
        if self._process_mouse_button_event(event):
            consumed_event = True
        if self.focused and event.type == pygame.KEYDOWN:
            if self._process_keyboard_shortcut_event(event):
                consumed_event = True
            elif self._process_action_key_event(event):
                consumed_event = True
            elif self._process_text_entry_key(event):
                consumed_event = True
        return consumed_event

    def _process_text_entry_key(self, event: pygame.event.Event) -> bool:
        """
        Process key input that can be added to the text entry text.

        :param event: The event to process.

        :return: True if consumed.
        """
        consumed_event = False
        within_length_limit = True
        if (self.length_limit is not None and
            (len(self.text) - abs(self.select_range[0] - self.select_range[1]))
                >= self.length_limit):
            within_length_limit = False
        if within_length_limit:
            character = event.unicode
            char_metrics = self.font.metrics(character)
            if len(char_metrics) > 0 and char_metrics[0] is not None:
                valid_character = True
                if (self.allowed_characters is not None
                        and character not in self.allowed_characters):
                    valid_character = False
                if (self.forbidden_characters is not None
                        and character in self.forbidden_characters):
                    valid_character = False
                if valid_character:
                    if abs(self.select_range[0] - self.select_range[1]) > 0:
                        low_end = min(self.select_range[0],
                                      self.select_range[1])
                        high_end = max(self.select_range[0],
                                       self.select_range[1])
                        self.text = self.text[:low_end] + character + self.text[
                            high_end:]
                        self.edit_position = low_end + 1
                        self.select_range = [0, 0]
                    else:
                        start_str = self.text[:self.edit_position]
                        end_str = self.text[self.edit_position:]
                        self.text = start_str + character + end_str
                        self.edit_position += 1
                    self.cursor_has_moved_recently = True
                    consumed_event = True
        return consumed_event

    def _process_action_key_event(self, event: pygame.event.Event) -> bool:
        """
        Check if event is one of the keys that triggers an action like deleting, or moving
        the edit position.

        :param event: The event to check.

        :return: True if event is consumed.

        """
        consumed_event = False
        if event.key == pygame.K_RETURN:
            event_data = {
                'user_type': UI_TEXT_ENTRY_FINISHED,
                'text': self.text,
                'ui_element': self,
                'ui_object_id': self.most_specific_combined_id
            }
            pygame.event.post(pygame.event.Event(pygame.USEREVENT, event_data))
            consumed_event = True
        elif event.key == pygame.K_BACKSPACE:
            if abs(self.select_range[0] - self.select_range[1]) > 0:
                low_end = min(self.select_range[0], self.select_range[1])
                high_end = max(self.select_range[0], self.select_range[1])
                self.text = self.text[:low_end] + self.text[high_end:]
                self.edit_position = low_end
                self.select_range = [0, 0]
                self.cursor_has_moved_recently = True
            elif self.edit_position > 0:
                if self.start_text_offset > 0:
                    self.start_text_offset -= self.font.size(
                        self.text[self.edit_position - 1])[0]
                self.text = self.text[:self.edit_position -
                                      1] + self.text[self.edit_position:]
                self.edit_position -= 1
                self.cursor_has_moved_recently = True
            consumed_event = True
        elif event.key == pygame.K_DELETE:
            if abs(self.select_range[0] - self.select_range[1]) > 0:
                low_end = min(self.select_range[0], self.select_range[1])
                high_end = max(self.select_range[0], self.select_range[1])
                self.text = self.text[:low_end] + self.text[high_end:]
                self.edit_position = low_end
                self.select_range = [0, 0]
                self.cursor_has_moved_recently = True
            elif self.edit_position < len(self.text):
                self.text = self.text[:self.edit_position] + self.text[
                    self.edit_position + 1:]
                self.edit_position = self.edit_position
                self.cursor_has_moved_recently = True
            consumed_event = True
        elif self._process_edit_pos_move_key(event):
            consumed_event = True

        return consumed_event

    def _process_edit_pos_move_key(self, event: pygame.event.Event) -> bool:
        """
        Process an action key that is moving the cursor edit position.

        :param event: The event to process.

        :return: True if event is consumed.

        """
        consumed_event = False
        if event.key == pygame.K_LEFT:
            if abs(self.select_range[0] - self.select_range[1]) > 0:
                self.edit_position = min(self.select_range[0],
                                         self.select_range[1])
                self.select_range = [0, 0]
                self.cursor_has_moved_recently = True
            elif self.edit_position > 0:
                self.edit_position -= 1
                self.cursor_has_moved_recently = True
            consumed_event = True
        elif event.key == pygame.K_RIGHT:
            if abs(self.select_range[0] - self.select_range[1]) > 0:
                self.edit_position = max(self.select_range[0],
                                         self.select_range[1])
                self.select_range = [0, 0]
                self.cursor_has_moved_recently = True
            elif self.edit_position < len(self.text):
                self.edit_position += 1
                self.cursor_has_moved_recently = True
            consumed_event = True
        return consumed_event

    def _process_keyboard_shortcut_event(self,
                                         event: pygame.event.Event) -> bool:
        """
        Check if event is one of the CTRL key keyboard shortcuts.

        :param event: event to process.

        :return: True if event consumed.

        """
        consumed_event = False
        if event.key == pygame.K_a and event.mod & pygame.KMOD_CTRL:
            self.select_range = [0, len(self.text)]
            self.edit_position = len(self.text)
            self.cursor_has_moved_recently = True
            consumed_event = True
        elif event.key == pygame.K_x and event.mod & pygame.KMOD_CTRL:
            if abs(self.select_range[0] - self.select_range[1]) > 0:
                low_end = min(self.select_range[0], self.select_range[1])
                high_end = max(self.select_range[0], self.select_range[1])
                clipboard_copy(self.text[low_end:high_end])
                self.text = self.text[:low_end] + self.text[high_end:]
                self.edit_position = low_end
                self.select_range = [0, 0]
                self.cursor_has_moved_recently = True
                consumed_event = True
        elif event.key == pygame.K_c and event.mod & pygame.KMOD_CTRL:
            if abs(self.select_range[0] - self.select_range[1]) > 0:
                low_end = min(self.select_range[0], self.select_range[1])
                high_end = max(self.select_range[0], self.select_range[1])
                clipboard_copy(self.text[low_end:high_end])
                consumed_event = True
        elif self._process_paste_event(event):
            consumed_event = True
        return consumed_event

    def _process_paste_event(self, event: pygame.event.Event) -> bool:
        """
        Process a paste shortcut event. (CTRL+ V)

        :param event: The event to process.

        :return: True if the event is consumed.

        """
        consumed_event = False
        if event.key == pygame.K_v and event.mod & pygame.KMOD_CTRL:
            new_text = clipboard_paste()
            if self.validate_text_string(new_text):
                if abs(self.select_range[0] - self.select_range[1]) > 0:
                    low_end = min(self.select_range[0], self.select_range[1])
                    high_end = max(self.select_range[0], self.select_range[1])
                    final_text = self.text[:low_end] + new_text + self.text[
                        high_end:]
                    within_length_limit = True
                    if self.length_limit is not None and len(
                            final_text) > self.length_limit:
                        within_length_limit = False
                    if within_length_limit:
                        self.text = final_text
                        self.edit_position = low_end + len(new_text)
                        self.select_range = [0, 0]
                        self.cursor_has_moved_recently = True
                elif len(new_text) > 0:
                    final_text = (self.text[:self.edit_position] + new_text +
                                  self.text[self.edit_position:])
                    within_length_limit = True
                    if self.length_limit is not None and len(
                            final_text) > self.length_limit:
                        within_length_limit = False
                    if within_length_limit:
                        self.text = final_text
                        self.edit_position += len(new_text)
                        self.cursor_has_moved_recently = True
                consumed_event = True
        return consumed_event

    def _process_mouse_button_event(self, event: pygame.event.Event) -> bool:
        """
        Process a mouse button event.

        :param event: Event to process.

        :return: True if we consumed the mouse event.

        """
        consumed_event = False
        if event.type == pygame.MOUSEBUTTONDOWN and event.button == pygame.BUTTON_LEFT:
            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]):

                pixel_x_pos = self.start_text_offset + scaled_mouse_pos[0]
                self.edit_position = self.find_edit_position_from_pixel_pos(
                    pixel_x_pos)

                if self.double_click_timer < self.ui_manager.get_double_click_time(
                ):
                    self._calculate_double_click_word_selection()
                else:
                    self.select_range[0] = self.edit_position
                    self.select_range[1] = self.edit_position
                    self.cursor_has_moved_recently = True
                    self.selection_in_progress = True
                    self.double_click_timer = 0.0

                consumed_event = True
        if (event.type == pygame.MOUSEBUTTONUP
                and event.button == pygame.BUTTON_LEFT
                and self.selection_in_progress):
            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.drawable_shape.collide_point(scaled_mouse_pos):
                consumed_event = True
                pixel_x_pos = self.start_text_offset + scaled_mouse_pos[0]
                new_edit_pos = self.find_edit_position_from_pixel_pos(
                    pixel_x_pos)
                if new_edit_pos != self.edit_position:
                    self.edit_position = new_edit_pos
                    self.cursor_has_moved_recently = True
                    self.select_range[1] = self.edit_position
            self.selection_in_progress = False
        return consumed_event

    def _calculate_double_click_word_selection(self):
        """
        If we double clicked on a word in the text, select that word.

        """
        if self.edit_position != self.select_range[0]:
            return
        index = min(self.edit_position, len(self.text) - 1)
        if index > 0:
            char = self.text[index]
            # Check we clicked in the same place on a our second click.
            pattern = re.compile(r"[\w']+")
            while not pattern.match(char):
                index -= 1
                if index > 0:
                    char = self.text[index]
                else:
                    break
            while pattern.match(char):
                index -= 1
                if index > 0:
                    char = self.text[index]
                else:
                    break
            start_select_index = index + 1 if index > 0 else index
            index += 1
            char = self.text[index]
            while index < len(self.text) and pattern.match(char):
                index += 1
                if index < len(self.text):
                    char = self.text[index]
            end_select_index = index

            self.select_range[0] = start_select_index
            self.select_range[1] = end_select_index
            self.edit_position = end_select_index
            self.cursor_has_moved_recently = True
            self.selection_in_progress = False

    def find_edit_position_from_pixel_pos(self, pixel_pos: int) -> int:
        """
        Locates the correct position to move the edit cursor to, when reacting to a mouse click
        inside the text entry element.

        :param pixel_pos: The x position of our click after being adjusted for text in our box
                          scrolling off-screen.

        """
        start_pos = (self.rect.x + self.border_width + self.shadow_width +
                     self.shape_corner_radius + self.horiz_line_padding)
        acc_pos = start_pos
        index = 0
        for char in self.text:
            x_width = self.font.size(char)[0]

            if acc_pos + (x_width / 2) > pixel_pos:
                break

            index += 1
            acc_pos += x_width
        return index

    def set_allowed_characters(self, allowed_characters: Union[str,
                                                               List[str]]):
        """
        Sets a whitelist of characters that will be the only ones allowed in our text entry
        element. We can either set the list directly, or request one of the already existing
        lists by a string identifier. The currently supported lists for allowed characters are:

        - 'numbers'

        :param allowed_characters: The characters to allow, either in a list form or one of the
                                   supported string ids.

        """
        if isinstance(allowed_characters, str):
            if allowed_characters == 'numbers':
                self.allowed_characters = UITextEntryLine._number_character_set
            else:
                warnings.warn(
                    'Trying to set allowed characters by type string, but no match: '
                    'did you mean to use a list?')

        else:
            self.allowed_characters = allowed_characters.copy()

    def set_forbidden_characters(self, forbidden_characters: Union[str,
                                                                   List[str]]):
        """
        Sets a blacklist of characters that will be banned from our text entry element.
        We can either set the list directly, or request one of the already existing lists by a
        string identifier. The currently supported lists for forbidden characters are:

        - 'numbers'
        - 'forbidden_file_path'

        :param forbidden_characters: The characters to forbid, either in a list form or one of
                                     the supported string ids.

        """
        if isinstance(forbidden_characters, str):
            if forbidden_characters == 'numbers':
                self.forbidden_characters = UITextEntryLine._number_character_set
            elif forbidden_characters == 'forbidden_file_path':
                self.forbidden_characters = UITextEntryLine._forbidden_file_path_characters
            else:
                warnings.warn(
                    'Trying to set forbidden characters by type string, but no match: '
                    'did you mean to use a list?')

        else:
            self.forbidden_characters = forbidden_characters.copy()

    def validate_text_string(self, text_to_validate: str) -> bool:
        """
        Checks a string of text to see if any of it's characters don't meet the requirements of
        the allowed and forbidden character sets.

        :param text_to_validate: The text string to check.

        """
        is_valid = True
        if self.forbidden_characters is not None:
            for character in text_to_validate:
                if character in self.forbidden_characters:
                    is_valid = False

        if is_valid and self.allowed_characters is not None:
            for character in text_to_validate:
                if character not in self.allowed_characters:
                    is_valid = False

        return is_valid

    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

        font = self.ui_theme.get_font(self.object_ids, self.element_ids)
        if font != self.font:
            self.font = font
            has_any_changed = True

        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_str = self.ui_theme.get_misc_data(self.object_ids,
                                                  self.element_ids, 'padding')
        if padding_str is None:
            horiz_line_padding = 2
            vert_line_padding = 2
        else:
            try:
                horiz_line_padding = int(padding_str.split(',')[0])
                vert_line_padding = int(padding_str.split(',')[1])
            except ValueError:
                horiz_line_padding = 2
                vert_line_padding = 2

        if (horiz_line_padding != self.horiz_line_padding
                or vert_line_padding != self.vert_line_padding):
            self.vert_line_padding = vert_line_padding
            self.horiz_line_padding = horiz_line_padding
            has_any_changed = True

        if self._check_theme_colours_changed():
            has_any_changed = True

        if has_any_changed:
            self.rebuild()

    def _check_theme_colours_changed(self):
        """
        Check if any colours have changed in the theme.

        :return: colour has changed.

        """
        has_any_changed = False
        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
        text_colour = self.ui_theme.get_colour_or_gradient(
            self.object_ids, self.element_ids, 'normal_text')
        if text_colour != self.text_colour:
            self.text_colour = text_colour
            has_any_changed = True
        selected_text_colour = self.ui_theme.get_colour_or_gradient(
            self.object_ids, self.element_ids, 'selected_text')
        if selected_text_colour != self.selected_text_colour:
            self.selected_text_colour = selected_text_colour
            has_any_changed = True
        selected_bg_colour = self.ui_theme.get_colour_or_gradient(
            self.object_ids, self.element_ids, 'selected_bg')
        if selected_bg_colour != self.selected_bg_colour:
            self.selected_bg_colour = selected_bg_colour
            has_any_changed = True
        return has_any_changed

    def set_dimensions(self, dimensions: Union[pygame.math.Vector2,
                                               Tuple[int, int], Tuple[float,
                                                                      float]]):
        """
        Will allow us to change the width of the text entry line, but not it's height which is
        determined by the height of the font.

        :param dimensions: The dimensions to set. Only the first, the width, will actually be used.

        """
        corrected_dimensions = [int(dimensions[0]), int(dimensions[1])]
        line_height = self.font.size(' ')[1]
        corrected_dimensions[1] = int(line_height +
                                      (2 * self.vert_line_padding) +
                                      (2 * self.border_width) +
                                      (2 * self.shadow_width))
        super().set_dimensions(
            (corrected_dimensions[0], corrected_dimensions[1]))

        self.text_image_rect = pygame.Rect(
            (self.border_width + self.shadow_width + self.shape_corner_radius,
             self.border_width + self.shadow_width),
            (self.relative_rect.width - (self.border_width * 2) -
             (self.shadow_width * 2) -
             (2 * self.shape_corner_radius), self.relative_rect.height -
             (self.border_width * 2) - (self.shadow_width * 2)))

        if self.drawable_shape is not None:
            self.background_and_border = self.drawable_shape.get_fresh_surface(
            )
            self.text_image = pygame.Surface(self.text_image_rect.size,
                                             flags=pygame.SRCALPHA,
                                             depth=32)
            self.redraw()
Example #3
0
class UIScreenSpaceHealthBar(UIElement):
    """
    A UI that will display health capacity and current health for a sprite in 'screen space'.
    That means it won't move with the camera. This is a good choice for a user/player sprite.

    :param relative_rect: The rectangle that defines the size and position of the health bar.
    :param manager: The UIManager that manages this element.
    :param sprite_to_monitor: The sprite we are displaying the health of.
    :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,
                 relative_rect: pygame.Rect,
                 manager: IUIManagerInterface,
                 sprite_to_monitor: Union[pygame.sprite.Sprite, None] = None,
                 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=1,
                         layer_thickness=1,
                         anchors=anchors,
                         visible=visible)

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

        self.current_health = 50
        self.health_capacity = 100
        self.health_percentage = self.current_health / self.health_capacity

        self.font = None
        self.border_width = None
        self.shadow_width = None
        self.border_colour = None
        self.bar_unfilled_colour = None
        self.bar_filled_colour = None
        self.text_shadow_colour = None
        self.text_colour = None
        self.text_horiz_alignment = 'center'
        self.text_vert_alignment = 'center'
        self.text_horiz_alignment_padding = 1
        self.text_vert_alignment_padding = 1

        self.border_rect = None
        self.capacity_width = None
        self.capacity_height = None
        self.capacity_rect = None
        self.current_health_rect = None

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

        if sprite_to_monitor is not None:
            if not hasattr(sprite_to_monitor, 'health_capacity'):
                raise AttributeError(
                    'Sprite does not have health_capacity attribute')
            if not hasattr(sprite_to_monitor, 'current_health'):
                raise AttributeError(
                    'Sprite does not have current_health attribute')
            self.sprite_to_monitor = sprite_to_monitor
        else:
            self.sprite_to_monitor = None
        self.set_image(None)
        self.background_text = None
        self.foreground_text = None

        self.rebuild_from_changed_theme_data()

    def set_sprite_to_monitor(self, sprite_to_monitor: pygame.sprite.Sprite):
        """
        Sprite to monitor the health of. Must have 'health_capacity' and 'current_health'
        attributes.

        :param sprite_to_monitor:

        """
        if not hasattr(sprite_to_monitor, 'health_capacity'):
            raise AttributeError(
                'Sprite does not have health_capacity attribute')
        if not hasattr(sprite_to_monitor, 'current_health'):
            raise AttributeError(
                'Sprite does not have current_health attribute')
        self.sprite_to_monitor = sprite_to_monitor

    def rebuild(self):
        """
        Rebuild the health bar entirely because the theming data has changed.

        """
        border_rect_width = self.rect.width - (self.shadow_width * 2)
        border_rect_height = self.rect.height - (self.shadow_width * 2)
        self.border_rect = pygame.Rect((self.shadow_width, self.shadow_width),
                                       (border_rect_width, border_rect_height))

        self.capacity_width = self.rect.width - (self.shadow_width *
                                                 2) - self.border_width * 2
        self.capacity_height = self.rect.height - (self.shadow_width *
                                                   2) - self.border_width * 2
        self.capacity_rect = pygame.Rect(
            (self.shadow_width + self.border_width,
             self.shadow_width + self.border_width),
            (self.capacity_width, self.capacity_height))

        self.current_health_rect = pygame.Rect(
            (self.shadow_width + self.border_width,
             self.shadow_width + self.border_width),
            (int(self.capacity_width * self.health_percentage),
             self.capacity_height))

        self.redraw()

    def redraw(self):
        """
        Redraws the health bar rectangles and text onto the underlying sprite's image surface.
        Takes a little while so we only do it when the health has changed.
        """
        health_display_string = str(self.current_health) + "/" + str(
            self.health_capacity)

        theming_parameters = {
            'normal_bg': self.bar_unfilled_colour,
            'normal_border': self.border_colour,
            'border_width': self.border_width,
            'shadow_width': self.shadow_width,
            'shape_corner_radius': self.shape_corner_radius,
            'filled_bar': self.bar_filled_colour,
            'filled_bar_width_percentage': self.health_percentage,
            'font': self.font,
            'text': health_display_string,
            'normal_text': self.text_colour,
            'text_shadow': self.text_shadow_colour,
            'text_horiz_alignment': self.text_horiz_alignment,
            'text_vert_alignment': self.text_vert_alignment,
            'text_horiz_alignment_padding': self.text_horiz_alignment_padding,
            'text_vert_alignment_padding': self.text_vert_alignment_padding,
        }

        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.set_image(self.drawable_shape.get_fresh_surface())

    def update(self, time_delta: float):
        """
        Updates the health bar sprite's image with the latest health data from the
        sprite we are monitoring. Only triggers a rebuild if the health values have changed.

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

        """
        super().update(time_delta)
        if (self.alive() and self.sprite_to_monitor is not None and
            (self.sprite_to_monitor.health_capacity != self.health_capacity
             or self.current_health != self.sprite_to_monitor.current_health)):
            self.current_health = self.sprite_to_monitor.current_health
            self.health_capacity = self.sprite_to_monitor.health_capacity
            self.health_percentage = self.current_health / self.health_capacity

            rect_width = int(self.capacity_width * self.health_percentage)
            self.current_health_rect = pygame.Rect(
                (self.shadow_width + self.border_width,
                 self.shadow_width + self.border_width),
                (rect_width, self.capacity_height))
            self.redraw()

    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

        font = self.ui_theme.get_font(self.combined_element_ids)
        if font != self.font:
            self.font = font
            has_any_changed = True

        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

        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

        bar_unfilled_colour = self.ui_theme.get_colour_or_gradient(
            'unfilled_bar', self.combined_element_ids)
        if bar_unfilled_colour != self.bar_unfilled_colour:
            self.bar_unfilled_colour = bar_unfilled_colour
            has_any_changed = True

        bar_filled_colour = self.ui_theme.get_colour_or_gradient(
            'filled_bar', self.combined_element_ids)
        if bar_filled_colour != self.bar_filled_colour:
            self.bar_filled_colour = bar_filled_colour
            has_any_changed = True

        text_shadow_colour = self.ui_theme.get_colour(
            'text_shadow', self.combined_element_ids)
        if text_shadow_colour != self.text_shadow_colour:
            self.text_shadow_colour = text_shadow_colour
            has_any_changed = True

        text_colour = self.ui_theme.get_colour_or_gradient(
            'normal_text', self.combined_element_ids)
        if text_colour != self.text_colour:
            self.text_colour = text_colour
            has_any_changed = True

        if has_any_changed:
            self.rebuild()
class UIWorldSpaceHealthBar(UIElement):
    """
    A UI that will display a sprite's 'health_capacity' and their 'current_health' in 'world space'
    above the sprite. This means that the health bar will move with the camera and the sprite
    itself.

    A sprite passed to this class must have the attributes 'health_capacity' and 'current_health'.

    :param relative_rect: The rectangle that defines the size of the health bar.
    :param sprite_to_monitor: The sprite we are displaying the health of.
    :param manager: The UIManager that manages this element.
    :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.
    """
    class ExampleHealthSprite(pygame.sprite.Sprite):
        """
        An example sprite with health instance attributes.

        :param groups: Sprite groups to put the sprite in.

        """
        def __init__(self, *groups):
            super().__init__(*groups)
            self.current_health = 50
            self.health_capacity = 100
            self.rect = pygame.Rect(0, 0, 32, 64)

    def __init__(self,
                 relative_rect: pygame.Rect,
                 sprite_to_monitor: Union[pygame.sprite.Sprite,
                                          ExampleHealthSprite],
                 manager: IUIManagerInterface,
                 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=1,
                         layer_thickness=1,
                         anchors=anchors,
                         visible=visible)

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

        if sprite_to_monitor is not None:
            if not hasattr(sprite_to_monitor, 'health_capacity'):
                raise AttributeError(
                    'Sprite does not have health_capacity attribute')
            if not hasattr(sprite_to_monitor, 'current_health'):
                raise AttributeError(
                    'Sprite does not have current_health attribute')
            self.sprite_to_monitor = sprite_to_monitor
        else:
            self.sprite_to_monitor = None
            raise AssertionError('Need sprite to monitor')

        self.current_health = self.sprite_to_monitor.current_health
        self.health_capacity = self.sprite_to_monitor.health_capacity
        self.health_percentage = self.current_health / self.health_capacity

        self.border_colour = None
        self.health_empty_colour = None
        self.bar_filled_colour = None
        self.bar_unfilled_colour = None
        self.health_colour = None
        self.hover_height = None
        self.border_width = None
        self.shadow_width = None
        self.position = None
        self.border_rect = None
        self.capacity_width = None
        self.capacity_height = None
        self.health_capacity_rect = None
        self.current_health_rect = None

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

        self.set_image(None)

        self.rebuild_from_changed_theme_data()

    def rebuild(self):
        """
        Rebuild the health bar entirely because the theming data has changed.

        """

        self.position = [
            self.sprite_to_monitor.rect.x,
            self.sprite_to_monitor.rect.y - self.hover_height
        ]

        self.rect.x = self.position[0]
        self.rect.y = self.position[1]

        self.border_rect = pygame.Rect(
            (self.shadow_width, self.shadow_width),
            (self.rect.width - (self.shadow_width * 2), self.rect.height -
             (self.shadow_width * 2)))

        self.capacity_width = self.rect.width - (self.shadow_width *
                                                 2) - (self.border_width * 2)
        self.capacity_height = self.rect.height - (self.shadow_width *
                                                   2) - (self.border_width * 2)
        self.health_capacity_rect = pygame.Rect(
            (self.border_width + self.shadow_width,
             self.border_width + self.shadow_width),
            (self.capacity_width, self.capacity_height))

        self.current_health_rect = pygame.Rect(
            (self.border_width + self.shadow_width,
             self.border_width + self.shadow_width),
            (int(self.capacity_width * self.health_percentage),
             self.capacity_height))

        self.redraw()

    def update(self, time_delta: float):
        """
        Updates the health bar sprite's image and rectangle with the latest health and position
        data from the sprite we are monitoring

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

        """
        super().update(time_delta)
        if self.alive():
            self.position = [
                self.sprite_to_monitor.rect.x,
                self.sprite_to_monitor.rect.y - self.hover_height
            ]

            self.rect.x = self.position[0]
            self.rect.y = self.position[1]
            self.relative_rect.topleft = self.rect.topleft

            if (self.current_health != self.sprite_to_monitor.current_health
                ) or (self.health_capacity !=
                      self.sprite_to_monitor.health_capacity):

                self.current_health = self.sprite_to_monitor.current_health
                self.health_capacity = self.sprite_to_monitor.health_capacity
                self.health_percentage = self.current_health / self.health_capacity

                self.redraw()

    def redraw(self):
        """
        Redraw the health bar when something, other than it's position has changed.
        """
        self.current_health_rect.width = int(self.capacity_width *
                                             self.health_percentage)

        theming_parameters = {
            'normal_bg': self.bar_unfilled_colour,
            'normal_border': self.border_colour,
            'border_width': self.border_width,
            'shadow_width': self.shadow_width,
            'shape_corner_radius': self.shape_corner_radius,
            'filled_bar': self.bar_filled_colour,
            'filled_bar_width_percentage': self.health_percentage
        }

        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.set_image(self.drawable_shape.get_fresh_surface())

    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

        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='hover_height',
                                               default_value=1,
                                               casting_func=int):
            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

        bar_unfilled_colour = self.ui_theme.get_colour_or_gradient(
            'unfilled_bar', self.combined_element_ids)
        if bar_unfilled_colour != self.bar_unfilled_colour:
            self.bar_unfilled_colour = bar_unfilled_colour
            has_any_changed = True

        bar_filled_colour = self.ui_theme.get_colour_or_gradient(
            'filled_bar', self.combined_element_ids)
        if bar_filled_colour != self.bar_filled_colour:
            self.bar_filled_colour = bar_filled_colour
            has_any_changed = True

        if has_any_changed:
            self.rebuild()
class UIVerticalScrollBar(UIElement):
    """
    A vertical scroll bar allows users to position a smaller visible area within a vertically
    larger area.

    :param relative_rect: The size and position of the scroll bar.
    :param visible_percentage: The vertical percentage of the larger area that is visible,
                               between 0.0 and 1.0.
    :param manager: The UIManager that manages this element.
    :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,
                 relative_rect: pygame.Rect,
                 visible_percentage: float,
                 manager: IUIManagerInterface,
                 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,
                         layer_thickness=2,
                         starting_height=1,
                         anchors=anchors,
                         visible=visible)

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

        self.button_height = 20
        self.arrow_button_height = self.button_height
        self.scroll_position = 0.0
        self.top_limit = 0.0
        self.starting_grab_y_difference = 0
        self.visible_percentage = max(0.0, min(visible_percentage, 1.0))
        self.start_percentage = 0.0

        self.grabbed_slider = False
        self.has_moved_recently = False
        self.scroll_wheel_moved = False
        self.scroll_wheel_amount = 0

        self.background_colour = None
        self.border_colour = None
        self.disabled_border_colour = None
        self.disabled_background_colour = None

        self.border_width = None
        self.shadow_width = None

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

        self.background_rect = None  # type: Union[None, pygame.Rect]

        self.scrollable_height = None  # type: Union[None, int, float]
        self.bottom_limit = None
        self.sliding_rect_position = None  # type: Union[None, pygame.math.Vector2]

        self.top_button = None
        self.bottom_button = None
        self.sliding_button = None
        self.enable_arrow_buttons = True

        self.button_container = None

        self.rebuild_from_changed_theme_data()

        scroll_bar_height = max(
            5, int(self.scrollable_height * self.visible_percentage))
        self.sliding_button = UIButton(pygame.Rect(
            (int(self.sliding_rect_position[0]),
             int(self.sliding_rect_position[1])),
            (self.background_rect.width, scroll_bar_height)),
                                       '',
                                       self.ui_manager,
                                       container=self.button_container,
                                       starting_height=1,
                                       parent_element=self,
                                       object_id="#sliding_button",
                                       anchors={
                                           'left': 'left',
                                           'right': 'right',
                                           'top': 'top',
                                           'bottom': 'top'
                                       })
        self.join_focus_sets(self.sliding_button)
        self.sliding_button.set_hold_range((100, self.background_rect.height))

    def rebuild(self):
        """
        Rebuild anything that might need rebuilding.

        """
        border_and_shadow = self.border_width + self.shadow_width
        self.background_rect = pygame.Rect(
            (border_and_shadow + self.relative_rect.x,
             border_and_shadow + self.relative_rect.y),
            (self.relative_rect.width - (2 * border_and_shadow),
             self.relative_rect.height - (2 * border_and_shadow)))

        theming_parameters = {
            'normal_bg': self.background_colour,
            'normal_border': self.border_colour,
            'disabled_bg': self.disabled_background_colour,
            'disabled_border': self.disabled_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', 'disabled'],
                                                    self.ui_manager)
        elif self.shape == 'rounded_rectangle':
            self.drawable_shape = RoundedRectangleShape(
                self.rect, theming_parameters, ['normal', 'disabled'],
                self.ui_manager)

        self.set_image(self.drawable_shape.get_fresh_surface())

        if self.button_container is None:
            self.button_container = UIContainer(
                self.background_rect,
                manager=self.ui_manager,
                container=self.ui_container,
                anchors=self.anchors,
                object_id='#vert_scrollbar_buttons_container',
                visible=self.visible)
            self.join_focus_sets(self.button_container)
        else:
            self.button_container.set_dimensions(self.background_rect.size)
            self.button_container.set_relative_position(
                self.background_rect.topleft)

        if self.enable_arrow_buttons:
            self.arrow_button_height = self.button_height

            if self.top_button is None:
                self.top_button = UIButton(pygame.Rect(
                    (0, 0),
                    (self.background_rect.width, self.arrow_button_height)),
                                           '▲',
                                           self.ui_manager,
                                           container=self.button_container,
                                           starting_height=1,
                                           parent_element=self,
                                           object_id=ObjectID(
                                               "#top_button", "@arrow_button"),
                                           anchors={
                                               'left': 'left',
                                               'right': 'right',
                                               'top': 'top',
                                               'bottom': 'top'
                                           })
                self.join_focus_sets(self.top_button)

            if self.bottom_button is None:
                self.bottom_button = UIButton(pygame.Rect(
                    (0, -self.arrow_button_height),
                    (self.background_rect.width, self.arrow_button_height)),
                                              '▼',
                                              self.ui_manager,
                                              container=self.button_container,
                                              starting_height=1,
                                              parent_element=self,
                                              object_id=ObjectID(
                                                  "#bottom_button",
                                                  "@arrow_button"),
                                              anchors={
                                                  'left': 'left',
                                                  'right': 'right',
                                                  'top': 'bottom',
                                                  'bottom': 'bottom'
                                              })
                self.join_focus_sets(self.bottom_button)
        else:
            self.arrow_button_height = 0
            if self.top_button is not None:
                self.top_button.kill()
                self.top_button = None
            if self.bottom_button is not None:
                self.bottom_button.kill()
                self.bottom_button = None

        self.scrollable_height = self.background_rect.height - (
            2 * self.arrow_button_height)
        self.bottom_limit = self.scrollable_height

        scroll_bar_height = max(
            5, int(self.scrollable_height * self.visible_percentage))
        self.scroll_position = min(max(self.scroll_position, self.top_limit),
                                   self.bottom_limit - scroll_bar_height)

        x_pos = 0
        y_pos = (self.scroll_position + self.arrow_button_height)
        self.sliding_rect_position = pygame.math.Vector2(x_pos, y_pos)

        if self.sliding_button is not None:
            self.sliding_button.set_relative_position(
                self.sliding_rect_position)
            self.sliding_button.set_dimensions(
                (self.background_rect.width, scroll_bar_height))
            self.sliding_button.set_hold_range(
                (100, self.background_rect.height))

    def check_has_moved_recently(self) -> bool:
        """
        Returns True if the scroll bar was moved in the last call to the update function.

        :return: True if we've recently moved the scroll bar, False otherwise.

        """
        return self.has_moved_recently

    def kill(self):
        """
        Overrides the kill() method of the UI element class to kill all the buttons in the scroll
        bar and clear any of the parts of the scroll bar that are currently recorded as the
        'last focused vertical scroll bar element' on the ui manager.

        NOTE: the 'last focused' state on the UI manager is used so that the mouse wheel will
        move whichever scrollbar we last fiddled with even if we've been doing other stuff.
        This seems to be consistent with the most common mousewheel/scrollbar interactions
        used elsewhere.
        """
        self.button_container.kill()
        super().kill()

    def process_event(self, event: pygame.event.Event) -> bool:
        """
        Checks an event from pygame's event queue to see if the scroll bar needs to react to it.
        In this case it is just mousewheel events, mainly because the buttons that make up
        the scroll bar will handle the required mouse click events.

        :param event: The event to process.

        :return: Returns True if we've done something with the input event.

        """
        consumed_event = False

        if (self.is_enabled and self._check_is_focus_set_hovered()
                and event.type == pygame.MOUSEWHEEL):
            self.scroll_wheel_moved = True
            self.scroll_wheel_amount = event.y
            consumed_event = True

        return consumed_event

    def _check_is_focus_set_hovered(self) -> bool:
        """
        Check if this scroll bar's focus set is currently hovered in the UI.

        :return: True if it was.

        """
        return any(element.hovered for element in self.get_focus_set())

    def update(self, time_delta: float):
        """
        Called once per update loop of our UI manager. Deals largely with moving the scroll bar
        and updating the resulting 'start_percentage' variable that is then used by other
        'scrollable' UI elements to control the point they start drawing.

        Reacts to presses of the up and down arrow buttons, movement of the mouse wheel and
        dragging of the scroll bar itself.

        :param time_delta: A float, roughly representing the time in seconds between calls to this
                           method.

        """
        super().update(time_delta)
        self.has_moved_recently = False
        if self.alive():
            moved_this_frame = False
            if self.scroll_wheel_moved and (
                    self.scroll_position > self.top_limit
                    or self.scroll_position < self.bottom_limit):
                self.scroll_wheel_moved = False
                self.scroll_position -= self.scroll_wheel_amount * (750.0 *
                                                                    time_delta)
                self.scroll_position = min(
                    max(self.scroll_position,
                        self.top_limit), self.bottom_limit -
                    self.sliding_button.relative_rect.height)
                x_pos = 0
                y_pos = (self.scroll_position + self.arrow_button_height)
                self.sliding_button.set_relative_position((x_pos, y_pos))
                moved_this_frame = True
            elif self.top_button is not None and self.top_button.held:
                self.scroll_position -= (250.0 * time_delta)
                self.scroll_position = max(self.scroll_position,
                                           self.top_limit)
                x_pos = 0
                y_pos = (self.scroll_position + self.arrow_button_height)
                self.sliding_button.set_relative_position((x_pos, y_pos))
                moved_this_frame = True
            elif self.bottom_button is not None and self.bottom_button.held:
                self.scroll_position += (250.0 * time_delta)
                self.scroll_position = min(
                    self.scroll_position, self.bottom_limit -
                    self.sliding_button.relative_rect.height)
                x_pos = 0
                y_pos = (self.scroll_position + self.arrow_button_height)
                self.sliding_button.set_relative_position((x_pos, y_pos))

                moved_this_frame = True

            mouse_x, mouse_y = self.ui_manager.get_mouse_position()
            if self.sliding_button.held and self.sliding_button.in_hold_range(
                (mouse_x, mouse_y)):

                if not self.grabbed_slider:
                    self.grabbed_slider = True
                    real_scroll_pos = self.sliding_button.rect.top
                    self.starting_grab_y_difference = mouse_y - real_scroll_pos

                real_scroll_pos = self.sliding_button.rect.top
                current_grab_difference = mouse_y - real_scroll_pos
                adjustment_required = current_grab_difference - self.starting_grab_y_difference
                self.scroll_position = self.scroll_position + adjustment_required

                self.scroll_position = min(
                    max(self.scroll_position, self.top_limit),
                    self.bottom_limit - self.sliding_button.rect.height)

                x_pos = 0
                y_pos = (self.scroll_position + self.arrow_button_height)
                self.sliding_button.set_relative_position((x_pos, y_pos))
                moved_this_frame = True
            elif not self.sliding_button.held:
                self.grabbed_slider = False

            if moved_this_frame:
                self.start_percentage = self.scroll_position / self.scrollable_height
                if not self.has_moved_recently:
                    self.has_moved_recently = True

    def redraw_scrollbar(self):
        """
        Redraws the 'scrollbar' portion of the whole UI element. Called when we change the
        visible percentage.
        """
        self.scrollable_height = self.background_rect.height - (
            2 * self.arrow_button_height)
        self.bottom_limit = self.scrollable_height

        scroll_bar_height = max(
            5, int(self.scrollable_height * self.visible_percentage))

        x_pos = 0
        y_pos = (self.scroll_position + self.arrow_button_height)
        self.sliding_rect_position = pygame.math.Vector2(x_pos, y_pos)

        if self.sliding_button is None:
            self.sliding_button = UIButton(pygame.Rect(
                int(x_pos), int(y_pos), self.background_rect.width,
                scroll_bar_height),
                                           '',
                                           self.ui_manager,
                                           container=self.button_container,
                                           starting_height=1,
                                           parent_element=self,
                                           object_id="#sliding_button",
                                           anchors={
                                               'left': 'left',
                                               'right': 'right',
                                               'top': 'top',
                                               'bottom': 'top'
                                           })
            self.join_focus_sets(self.sliding_button)
        else:
            self.sliding_button.set_relative_position(
                self.sliding_rect_position)
            self.sliding_button.set_dimensions(
                (self.background_rect.width, scroll_bar_height))
        self.sliding_button.set_hold_range((100, self.background_rect.height))

    def set_visible_percentage(self, percentage: float):
        """
        Sets the percentage of the total 'scrollable area' that is currently visible. This will
        affect the size of the scrollbar and should be called if the vertical size of the
        'scrollable area' or the vertical size of the visible area change.

        :param percentage: A float between 0.0 and 1.0 representing the percentage that is visible.

        """
        self.visible_percentage = max(0.0, min(1.0, percentage))
        if 1.0 - self.start_percentage < self.visible_percentage:
            self.start_percentage = 1.0 - self.visible_percentage

        self.redraw_scrollbar()

    def reset_scroll_position(self):
        """
        Reset the current scroll position back to the top.

        """
        self.scroll_position = 0.0
        self.start_percentage = 0.0

    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

        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

        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

        disabled_background_colour = self.ui_theme.get_colour_or_gradient(
            'disabled_dark_bg', self.combined_element_ids)
        if disabled_background_colour != self.disabled_background_colour:
            self.disabled_background_colour = disabled_background_colour
            has_any_changed = True

        disabled_border_colour = self.ui_theme.get_colour_or_gradient(
            'disabled_border', self.combined_element_ids)
        if disabled_border_colour != self.disabled_border_colour:
            self.disabled_border_colour = disabled_border_colour
            has_any_changed = True

        def parse_to_bool(str_data: str):
            return bool(int(str_data))

        if self._check_misc_theme_data_changed(
                attribute_name='enable_arrow_buttons',
                default_value=True,
                casting_func=parse_to_bool):
            has_any_changed = True

        if has_any_changed:
            self.rebuild()

    def set_position(self, position: Union[pygame.math.Vector2,
                                           Tuple[int, int], Tuple[float,
                                                                  float]]):
        """
        Sets the absolute screen position of this scroll bar, 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
        self.background_rect.x = border_and_shadow + self.relative_rect.x
        self.background_rect.y = border_and_shadow + self.relative_rect.y

        self.button_container.set_relative_position(
            self.background_rect.topleft)

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

        :param position: The relative screen position to set.

        """
        super().set_relative_position(position)

        border_and_shadow = self.border_width + self.shadow_width
        self.background_rect.x = border_and_shadow + self.relative_rect.x
        self.background_rect.y = border_and_shadow + self.relative_rect.y

        self.button_container.set_relative_position(
            self.background_rect.topleft)

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

        :param dimensions: The new dimensions to set.

        """
        super().set_dimensions(dimensions)

        border_and_shadow = self.border_width + self.shadow_width
        self.background_rect.width = self.relative_rect.width - (
            2 * border_and_shadow)
        self.background_rect.height = self.relative_rect.height - (
            2 * border_and_shadow)

        self.button_container.set_dimensions(self.background_rect.size)

        # sort out scroll bar parameters
        self.scrollable_height = self.background_rect.height - (
            2 * self.arrow_button_height)
        self.bottom_limit = self.scrollable_height

        scroll_bar_height = max(
            5, int(self.scrollable_height * self.visible_percentage))
        base_scroll_bar_y = self.arrow_button_height
        max_scroll_bar_y = base_scroll_bar_y + (self.scrollable_height -
                                                scroll_bar_height)
        self.sliding_rect_position.y = max(
            base_scroll_bar_y,
            min((base_scroll_bar_y +
                 int(self.start_percentage * self.scrollable_height)),
                max_scroll_bar_y))
        self.scroll_position = self.sliding_rect_position.y - base_scroll_bar_y

        self.sliding_button.set_dimensions(
            (self.background_rect.width, scroll_bar_height))
        self.sliding_button.set_relative_position(self.sliding_rect_position)

    def disable(self):
        """
        Disables the scroll bar so it is no longer interactive.
        """
        if self.is_enabled:
            self.is_enabled = False
            self.button_container.disable()

            self.drawable_shape.set_active_state('disabled')

    def enable(self):
        """
        Enables the scroll bar so it is interactive once again.
        """
        if not self.is_enabled:
            self.is_enabled = True
            self.button_container.enable()

            self.drawable_shape.set_active_state('normal')

    def show(self):
        """
        In addition to the base UIElement.show() - show the self.button_container which will
        propagate and show all the buttons.
        """
        super().show()

        self.button_container.show()

    def hide(self):
        """
        In addition to the base UIElement.hide() - hide the self.button_container which will
        propagate and hide all the buttons.
        """
        super().hide()

        self.button_container.hide()
Example #6
0
class UIStatusBar(UIElement):
    """
    Displays a status/progress bar.

    This is a flexible class that can be used to display status for a sprite (health/mana/fatigue, etc),
    or to provide a status bar on the screen not attached to any particular object. You can use multiple
    status bars for a sprite to show different status items if desired.

    You can use the percent_full attribute to manually set the status, or you can provide a pointer to a method
    that will provide the percentage information.

    This is a kitchen sink class with several ways to use it; you may want to look at the subclasses built on top
    of it that are designed to be simpler to use, such as UIProgressBar, UIWorldSpaceHealthBar, and
    UIScreenSpaceHealthBar.

    :param relative_rect: The rectangle that defines the size of the health bar.
    :param sprite: Optional sprite to monitor for status info, and for drawing the bar with the sprite.
    :param follow_sprite: If there's a sprite, this indicates whether the bar should be drawn at the sprite's location.
    :param percent_method: Optional method signature to call to get the percent complete. (To provide a method signature,
                           simply reference the method without parenthesis, such as self.health_percent.)
    :param manager: The UIManager that manages this element.
    :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.

    """

    element_id = 'status_bar'

    def __init__(self,
                 relative_rect: pygame.Rect,
                 manager: IUIManagerInterface,
                 sprite: Union[pygame.sprite.Sprite, None] = None,
                 follow_sprite: bool = True,
                 percent_method: Union[Callable[[], float], None] = None,
                 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=1,
                         layer_thickness=1,
                         anchors=anchors,
                         visible=visible)

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

        self.sprite = sprite
        self.follow_sprite = follow_sprite
        self.follow_sprite_offset = (0, 0)

        self.percent_method = percent_method
        self._percent_full = 0
        self.status_changed = False

        self.border_colour = None
        self.bar_filled_colour = None
        self.bar_unfilled_colour = None
        self.hover_height = None
        self.border_width = None
        self.shadow_width = None
        self.border_rect = None
        self.capacity_width = None
        self.capacity_height = None
        self.capacity_rect = None
        self.current_status_rect = None

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

        self.font = None
        self.text_shadow_colour = None
        self.text_colour = None
        self.text_horiz_alignment = 'center'
        self.text_vert_alignment = 'center'
        self.text_horiz_alignment_padding = 1
        self.text_vert_alignment_padding = 1
        self.background_text = None
        self.foreground_text = None

        self.set_image(None)

        self.rebuild_from_changed_theme_data()

    @property
    def percent_full(self):
        """ Use this property to directly change the status bar. """
        return self._percent_full

    @percent_full.setter
    def percent_full(self, value):
        # We need a decimal percentage
        if value > 1:
            value = value / 100
        if value != self._percent_full:
            self._percent_full = value
            self.status_changed = True

    @property
    def position(self):
        if self.sprite and self.follow_sprite:
            offset_x = self.sprite.rect.x + self.follow_sprite_offset[0]
            offset_y = self.sprite.rect.y + self.follow_sprite_offset[1] - self.hover_height
            return offset_x, offset_y
        else:
            return self.relative_rect.x, self.relative_rect.y

    def rebuild(self):
        """
        Rebuild the status bar entirely because the theming data has changed.

        """
        self.rect.x, self.rect.y = self.position

        self.border_rect = pygame.Rect((self.shadow_width, self.shadow_width),
                                       (self.rect.width - (self.shadow_width * 2),
                                        self.rect.height - (self.shadow_width * 2)))

        self.capacity_width = self.rect.width - (self.shadow_width * 2) - (self.border_width * 2)
        self.capacity_height = self.rect.height - (self.shadow_width * 2) - (self.border_width * 2)
        self.capacity_rect = pygame.Rect((self.border_width + self.shadow_width,
                                          self.border_width + self.shadow_width),
                                         (self.capacity_width, self.capacity_height))

        self.redraw()

    def update(self, time_delta: float):
        """
        Updates the status bar sprite's image and rectangle with the latest status and position
        data from the sprite we are monitoring

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

        """
        super().update(time_delta)
        if self.alive():
            self.rect.x, self.rect.y = self.position
            self.relative_rect.topleft = self.rect.topleft

            # If they've provided a method to call, we'll track previous value in percent_full.
            if self.percent_method:
                # This triggers status_changed if necessary.
                self.percent_full = self.percent_method()

            if self.status_changed:
                self.status_changed = False
                self.redraw()

    def status_text(self):
        """ To display text in the bar, subclass UIStatusBar and override this method. """
        return None

    def redraw(self):
        """
        Redraw the status bar when something, other than it's position has changed.

        """
        theming_parameters = {'normal_bg': self.bar_unfilled_colour,
                              'normal_border': self.border_colour,
                              'border_width': self.border_width,
                              'shadow_width': self.shadow_width,
                              'shape_corner_radius': self.shape_corner_radius,
                              'filled_bar': self.bar_filled_colour,
                              'filled_bar_width_percentage': self.percent_full,
                              'follow_sprite_offset': self.follow_sprite_offset}

        text = self.status_text()
        if text:
            text_parameters = {'font': self.font,
                               'text': text,
                               'normal_text': self.text_colour,
                               'normal_text_shadow': self.text_shadow_colour,
                               'text_shadow': (1,
                                               0,
                                               0,
                                               self.text_shadow_colour,
                                               False),
                               'text_horiz_alignment': self.text_horiz_alignment,
                               'text_vert_alignment': self.text_vert_alignment,
                               'text_horiz_alignment_padding': self.text_horiz_alignment_padding,
                               'text_vert_alignment_padding': self.text_vert_alignment_padding,
                               }
            theming_parameters.update(text_parameters)

        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.set_image(self.drawable_shape.get_fresh_surface())

    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

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

        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='hover_height',
                                               default_value=1,
                                               casting_func=int):
            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

        bar_unfilled_colour = self.ui_theme.get_colour_or_gradient('unfilled_bar',
                                                                   self.combined_element_ids)
        if bar_unfilled_colour != self.bar_unfilled_colour:
            self.bar_unfilled_colour = bar_unfilled_colour
            has_any_changed = True

        bar_filled_colour = self.ui_theme.get_colour_or_gradient('filled_bar',
                                                                 self.combined_element_ids)
        if bar_filled_colour != self.bar_filled_colour:
            self.bar_filled_colour = bar_filled_colour
            has_any_changed = True

        if self.status_text:
            font = self.ui_theme.get_font(self.combined_element_ids)
            if font != self.font:
                self.font = font
                has_any_changed = True

            text_shadow_colour = self.ui_theme.get_colour('text_shadow', self.combined_element_ids)
            if text_shadow_colour != self.text_shadow_colour:
                self.text_shadow_colour = text_shadow_colour
                has_any_changed = True

            text_colour = self.ui_theme.get_colour_or_gradient('normal_text', self.combined_element_ids)
            if text_colour != self.text_colour:
                self.text_colour = text_colour
                has_any_changed = True

        if has_any_changed:
            self.rebuild()
class UIHorizontalSlider(UIElement):
    """
    A horizontal slider is intended to help users adjust values within a range, for example a
    volume control.

    :param relative_rect: A rectangle describing the position and dimensions of the element.
    :param start_value: The value to start the slider at.
    :param value_range: The full range of values.
    :param manager: The UIManager that manages this element.
    :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.
    :param click_increment: the amount to increment by when clicking one of the arrow buttons.
    """
    def __init__(self,
                 relative_rect: pygame.Rect,
                 start_value: Union[float, int],
                 value_range: Tuple[Union[float, int], Union[float, int]],
                 manager: IUIManagerInterface,
                 container: Union[IContainerLikeInterface, None] = None,
                 parent_element: UIElement = None,
                 object_id: Union[ObjectID, str, None] = None,
                 anchors: Dict[str, str] = None,
                 visible: int = 1,
                 click_increment: Union[float, int] = 1):

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

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

        self.default_button_width = 20
        self.arrow_button_width = self.default_button_width
        self.sliding_button_width = self.default_button_width
        self.current_percentage = 0.5
        self.left_limit_position = 0.0
        self.starting_grab_x_difference = 0

        if (isinstance(start_value, int) and isinstance(value_range[0], int)
                and isinstance(value_range[1], int)):
            self.use_integers_for_value = True
        else:
            self.use_integers_for_value = False
        self.value_range = value_range
        value_range_length = self.value_range[1] - self.value_range[0]

        self.current_value = self.value_range[0] + (self.current_percentage *
                                                    value_range_length)
        if self.use_integers_for_value:
            self.current_value = int(self.current_value)

        self.grabbed_slider = False
        self.has_moved_recently = False
        self.has_been_moved_by_user_recently = False

        self.background_colour = None
        self.border_colour = None
        self.disabled_border_colour = None
        self.disabled_background_colour = None

        self.border_width = None
        self.shadow_width = None

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

        self.background_rect = None  # type: Optional[pygame.Rect]

        self.scrollable_width = None
        self.right_limit_position = None
        self.scroll_position = None

        self.left_button = None
        self.right_button = None
        self.sliding_button = None
        self.enable_arrow_buttons = True

        self.button_container = None

        self.button_held_repeat_time = 0.2
        self.button_held_repeat_acc = 0.0

        self.increment = click_increment

        self.rebuild_from_changed_theme_data()

        sliding_x_pos = int(self.background_rect.width / 2 -
                            self.sliding_button_width / 2)
        self.sliding_button = UIButton(pygame.Rect(
            (sliding_x_pos, 0),
            (self.sliding_button_width, self.background_rect.height)),
                                       '',
                                       self.ui_manager,
                                       container=self.button_container,
                                       starting_height=1,
                                       parent_element=self,
                                       object_id=ObjectID(
                                           object_id='#sliding_button',
                                           class_id='None'),
                                       anchors={
                                           'left': 'left',
                                           'right': 'left',
                                           'top': 'top',
                                           'bottom': 'bottom'
                                       },
                                       visible=self.visible)

        self.sliding_button.set_hold_range((self.background_rect.width, 100))

        self.set_current_value(start_value)

    def rebuild(self):
        """
        Rebuild anything that might need rebuilding.

        """
        border_and_shadow = self.border_width + self.shadow_width
        self.background_rect = pygame.Rect(
            (border_and_shadow + self.relative_rect.x,
             border_and_shadow + self.relative_rect.y),
            (self.relative_rect.width - (2 * border_and_shadow),
             self.relative_rect.height - (2 * border_and_shadow)))

        theming_parameters = {
            'normal_bg': self.background_colour,
            'normal_border': self.border_colour,
            'disabled_bg': self.disabled_background_colour,
            'disabled_border': self.disabled_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', 'disabled'],
                                                    self.ui_manager)
        elif self.shape == 'rounded_rectangle':
            self.drawable_shape = RoundedRectangleShape(
                self.rect, theming_parameters, ['normal', 'disabled'],
                self.ui_manager)

        self.set_image(self.drawable_shape.get_fresh_surface())

        if self.button_container is None:
            self.button_container = UIContainer(
                self.background_rect,
                manager=self.ui_manager,
                container=self.ui_container,
                anchors=self.anchors,
                object_id='#horiz_scrollbar_buttons_container',
                visible=self.visible)
        else:
            self.button_container.set_dimensions(self.background_rect.size)
            self.button_container.set_relative_position(
                self.background_rect.topleft)

        # Things below here depend on theme data so need to be updated on a rebuild
        if self.enable_arrow_buttons:
            self.arrow_button_width = self.default_button_width

            if self.left_button is None:
                self.left_button = UIButton(pygame.Rect(
                    (0, 0),
                    (self.arrow_button_width, self.background_rect.height)),
                                            '◀',
                                            self.ui_manager,
                                            container=self.button_container,
                                            starting_height=1,
                                            parent_element=self,
                                            object_id=ObjectID(
                                                "#left_button",
                                                "@arrow_button"),
                                            anchors={
                                                'left': 'left',
                                                'right': 'left',
                                                'top': 'top',
                                                'bottom': 'bottom'
                                            },
                                            visible=self.visible)

            if self.right_button is None:
                self.right_button = UIButton(pygame.Rect(
                    (-self.arrow_button_width, 0),
                    (self.arrow_button_width, self.background_rect.height)),
                                             '▶',
                                             self.ui_manager,
                                             container=self.button_container,
                                             starting_height=1,
                                             parent_element=self,
                                             object_id=ObjectID(
                                                 "#right_button",
                                                 "@arrow_button"),
                                             anchors={
                                                 'left': 'right',
                                                 'right': 'right',
                                                 'top': 'top',
                                                 'bottom': 'bottom'
                                             },
                                             visible=self.visible)

        else:
            self.arrow_button_width = 0
            if self.left_button is not None:
                self.left_button.kill()
                self.left_button = None
            if self.right_button is not None:
                self.right_button.kill()
                self.right_button = None

        self.scrollable_width = (self.background_rect.width -
                                 self.sliding_button_width -
                                 (2 * self.arrow_button_width))
        self.right_limit_position = self.scrollable_width
        self.scroll_position = self.scrollable_width / 2

        if self.sliding_button is not None:
            sliding_x_pos = int((self.background_rect.width / 2) -
                                (self.sliding_button_width / 2))
            self.sliding_button.set_relative_position((sliding_x_pos, 0))
            self.sliding_button.set_dimensions(
                (self.sliding_button_width, self.background_rect.height))
            self.sliding_button.set_hold_range(
                (self.background_rect.width, 100))
            self.set_current_value(self.current_value, False)

    def kill(self):
        """
        Overrides the normal sprite kill() method to also kill the button elements that help make
        up the slider.

        """
        self.button_container.kill()
        super().kill()

    def update(self, time_delta: float):
        """
        Takes care of actually moving the slider based on interactions reported by the buttons or
        based on movement of the mouse if we are gripping the slider itself.

        :param time_delta: the time in seconds between calls to update.

        """
        super().update(time_delta)

        if not (self.alive() and self.is_enabled):
            return
        moved_this_frame = False
        moved_this_frame = self._update_arrow_buttons(moved_this_frame,
                                                      time_delta)

        mouse_x, mouse_y = self.ui_manager.get_mouse_position()
        if self.sliding_button.held and self.sliding_button.in_hold_range(
            (mouse_x, mouse_y)):
            if not self.grabbed_slider:
                self.grabbed_slider = True
                real_scroll_pos = self.sliding_button.rect.left
                self.starting_grab_x_difference = mouse_x - real_scroll_pos

            real_scroll_pos = self.sliding_button.rect.left
            current_grab_difference = mouse_x - real_scroll_pos
            adjustment_required = current_grab_difference - self.starting_grab_x_difference
            self.scroll_position = self.scroll_position + adjustment_required

            self.scroll_position = min(
                max(self.scroll_position, self.left_limit_position),
                self.right_limit_position)
            x_pos = (self.scroll_position + self.arrow_button_width)
            y_pos = 0
            self.sliding_button.set_relative_position((x_pos, y_pos))

            moved_this_frame = True
        elif not self.sliding_button.held:
            self.grabbed_slider = False

        if moved_this_frame:
            self.current_percentage = self.scroll_position / self.scrollable_width
            self.current_value = self.value_range[0] + (
                self.current_percentage *
                (self.value_range[1] - self.value_range[0]))
            if self.use_integers_for_value:
                self.current_value = int(self.current_value)
            if not self.has_moved_recently:
                self.has_moved_recently = True

            if not self.has_been_moved_by_user_recently:
                self.has_been_moved_by_user_recently = True

            # old event - to be removed in 0.8.0
            event_data = {
                'user_type': OldType(UI_HORIZONTAL_SLIDER_MOVED),
                'value': self.current_value,
                '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 = {
                'value': self.current_value,
                'ui_element': self,
                'ui_object_id': self.most_specific_combined_id
            }
            pygame.event.post(
                pygame.event.Event(UI_HORIZONTAL_SLIDER_MOVED, event_data))

    def _update_arrow_buttons(self, moved_this_frame, time_delta):
        if self.left_button is not None and (
                self.left_button.held
                and self.scroll_position > self.left_limit_position):

            if self.button_held_repeat_acc > self.button_held_repeat_time:
                self.scroll_position -= (250.0 * time_delta)
                self.scroll_position = max(self.scroll_position,
                                           self.left_limit_position)
                x_pos = (self.scroll_position + self.arrow_button_width)
                y_pos = 0
                self.sliding_button.set_relative_position((x_pos, y_pos))
                moved_this_frame = True
            else:
                self.button_held_repeat_acc += time_delta
        elif self.right_button is not None and (
                self.right_button.held
                and self.scroll_position < self.right_limit_position):
            if self.button_held_repeat_acc > self.button_held_repeat_time:
                self.scroll_position += (250.0 * time_delta)
                self.scroll_position = min(self.scroll_position,
                                           self.right_limit_position)
                x_pos = (self.scroll_position + self.arrow_button_width)
                y_pos = 0
                self.sliding_button.set_relative_position((x_pos, y_pos))
                moved_this_frame = True
            else:
                self.button_held_repeat_acc += time_delta
        else:
            self.button_held_repeat_acc = 0.0
        return moved_this_frame

    def process_event(self, event: pygame.event.Event) -> bool:
        processed_event = False
        if event.type == UI_BUTTON_PRESSED:
            if (event.ui_element in [self.left_button, self.right_button] and
                    self.button_held_repeat_acc < self.button_held_repeat_time
                    and (self.value_range[0] <= self.get_current_value() <=
                         self.value_range[1])):
                old_value = self.get_current_value()
                new_value = (old_value - self.increment if event.ui_element
                             == self.left_button else old_value +
                             self.increment)
                self.set_current_value(new_value, False)
                processed_event = True
                event_data = {
                    'value': self.current_value,
                    'ui_element': self,
                    'ui_object_id': self.most_specific_combined_id
                }
                pygame.event.post(
                    pygame.event.Event(UI_HORIZONTAL_SLIDER_MOVED, event_data))

        return processed_event

    def get_current_value(self) -> Union[float, int]:
        """
        Gets the current value the slider is set to.

        :return: The current value recorded by the slider.

        """
        self.has_moved_recently = False
        self.has_been_moved_by_user_recently = False
        return self.current_value

    def set_current_value(self, value: Union[float, int], warn: bool = True):
        """
        Sets the value of the slider, which will move the position of the slider to match. Will
        issue a warning if the value set is not in the value range.

        :param value: The value to set.
        :param warn: set to false to suppress the default warning,
                     instead the value will be clamped.

        """
        if self.use_integers_for_value:
            value = int(value)

        min_value = min(self.value_range[0], self.value_range[1])
        max_value = max(self.value_range[0], self.value_range[1])
        if value < min_value or value > max_value:
            if warn:
                warnings.warn('value not in range', UserWarning)
                return
            else:
                self.current_value = max(min(value, max_value), min_value)
        else:
            self.current_value = value

        value_range_size = (self.value_range[1] - self.value_range[0])
        if value_range_size != 0:
            self.current_percentage = (float(self.current_value) -
                                       self.value_range[0]) / value_range_size
            self.scroll_position = self.scrollable_width * self.current_percentage

            x_pos = (self.scroll_position + self.arrow_button_width)
            y_pos = 0
            self.sliding_button.set_relative_position((x_pos, y_pos))
            self.has_moved_recently = True

    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

        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

        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

        disabled_background_colour = self.ui_theme.get_colour_or_gradient(
            'disabled_dark_bg', self.combined_element_ids)
        if disabled_background_colour != self.disabled_background_colour:
            self.disabled_background_colour = disabled_background_colour
            has_any_changed = True

        disabled_border_colour = self.ui_theme.get_colour_or_gradient(
            'disabled_border', self.combined_element_ids)
        if disabled_border_colour != self.disabled_border_colour:
            self.disabled_border_colour = disabled_border_colour
            has_any_changed = True

        def parse_to_bool(str_data: str):
            return bool(int(str_data))

        if self._check_misc_theme_data_changed(
                attribute_name='enable_arrow_buttons',
                default_value=True,
                casting_func=parse_to_bool):
            has_any_changed = True

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

        if has_any_changed:
            self.rebuild()

    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
        self.background_rect.x = border_and_shadow + self.relative_rect.x
        self.background_rect.y = border_and_shadow + self.relative_rect.y

        self.button_container.set_relative_position(
            self.background_rect.topleft)

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

        :param position: The relative screen position to set.

        """
        super().set_relative_position(position)

        border_and_shadow = self.border_width + self.shadow_width
        self.background_rect.x = border_and_shadow + self.relative_rect.x
        self.background_rect.y = border_and_shadow + self.relative_rect.y

        self.button_container.set_relative_position(
            self.background_rect.topleft)

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

        :param dimensions: The new dimensions to set.

        """
        super().set_dimensions(dimensions)

        border_and_shadow = self.border_width + self.shadow_width
        self.background_rect.width = self.relative_rect.width - (
            2 * border_and_shadow)
        self.background_rect.height = self.relative_rect.height - (
            2 * border_and_shadow)

        self.button_container.set_dimensions(self.background_rect.size)

        # sort out sliding button parameters
        self.scrollable_width = (self.background_rect.width -
                                 self.sliding_button_width -
                                 (2 * self.arrow_button_width))
        self.right_limit_position = self.scrollable_width
        self.scroll_position = self.scrollable_width * self.current_percentage

        slider_x_pos = self.scroll_position + self.arrow_button_width
        slider_y_pos = 0

        self.sliding_button.set_dimensions(
            (self.sliding_button_width, self.background_rect.height))
        self.sliding_button.set_relative_position((slider_x_pos, slider_y_pos))

    def disable(self):
        """
        Disable the slider. It should not be interactive and will use the disabled theme colours.
        """
        if self.is_enabled:
            self.is_enabled = False
            self.sliding_button.disable()
            if self.left_button:
                self.left_button.disable()
            if self.right_button:
                self.right_button.disable()
            self.drawable_shape.set_active_state('disabled')

    def enable(self):
        """
        Enable the slider. It should become interactive and will use the normal theme colours.
        """
        if not self.is_enabled:
            self.is_enabled = True
            self.sliding_button.enable()
            if self.left_button:
                self.left_button.enable()
            if self.right_button:
                self.right_button.enable()
            self.drawable_shape.set_active_state('normal')

    def show(self):
        """
        In addition to the base UIElement.show() - show the sliding button and show
        the button_container which will propagate and show the left and right buttons.
        """
        super().show()

        self.sliding_button.show()
        if self.button_container is not None:
            self.button_container.show()

    def hide(self):
        """
        In addition to the base UIElement.hide() - hide the sliding button and hide
        the button_container which will propagate and hide the left and right buttons.
        """
        super().hide()

        self.sliding_button.hide()
        if self.button_container is not None:
            self.button_container.hide()
Example #8
0
class UIWindow(UIElement, IContainerLikeInterface, IWindowInterface):
    """
    A base class for window GUI elements, any windows should inherit from this class.

    :param rect: A rectangle, representing size and position of the window (including title bar,
                 shadow and borders).
    :param manager: The UIManager that manages this UIWindow.
    :param window_display_title: A string that will appear in the windows title bar if it has one.
    :param element_id: An element ID for this window, if one is not supplied it defaults to
                       'window'.
    :param object_id: An optional object ID for this window, useful for distinguishing different
                      windows.
    :param resizable: Whether this window is resizable or not, defaults to False.
    :param visible: Whether the element is visible by default. Warning - container visibility may
                    override this.
    """
    def __init__(self,
                 rect: pygame.Rect,
                 manager: IUIManagerInterface,
                 window_display_title: str = "",
                 element_id: Union[str, None] = None,
                 object_id: Union[ObjectID, str, None] = None,
                 resizable: bool = False,
                 visible: int = 1):

        self.window_display_title = window_display_title
        self._window_root_container = None  # type: Union[UIContainer, None]
        self.resizable = resizable
        self.minimum_dimensions = (100, 100)
        self.edge_hovering = [False, False, False, False]

        super().__init__(rect,
                         manager,
                         container=None,
                         starting_height=1,
                         layer_thickness=1,
                         visible=visible)

        if element_id is None:
            element_id = 'window'

        self._create_valid_ids(container=None,
                               parent_element=None,
                               object_id=object_id,
                               element_id=element_id)

        self.set_image(self.ui_manager.get_universal_empty_surface())
        self.bring_to_front_on_focused = True

        self.is_blocking = False  # blocks all clicking events from interacting beyond this window

        self.resizing_mode_active = False
        self.start_resize_point = (0, 0)
        self.start_resize_rect = None  # type: Union[pygame.Rect, None]

        self.grabbed_window = False
        self.starting_grab_difference = (0, 0)

        self.background_colour = None
        self.border_colour = None
        self.shape = 'rectangle'
        self.enable_title_bar = True
        self.enable_close_button = True
        self.title_bar_height = 28
        self.title_bar_close_button_width = self.title_bar_height

        # UI elements
        self.window_element_container = None  # type: Union[UIContainer, None]
        self.title_bar = None  # type: Union[UIButton, None]
        self.close_window_button = None  # type: Union[UIButton, None]

        self.rebuild_from_changed_theme_data()

        self.window_stack = self.ui_manager.get_window_stack()
        self.window_stack.add_new_window(self)

    def set_blocking(self, state: bool):
        """
        Sets whether this window being open should block clicks to the rest of the UI or not.
        Defaults to False.

        :param state: True if this window should block mouse clicks.

        """
        self.is_blocking = state

    def set_minimum_dimensions(self, dimensions: Union[pygame.math.Vector2,
                                                       Tuple[int, int],
                                                       Tuple[float, float]]):
        """
        If this window is resizable, then the dimensions we set here will be the minimum that
        users can change the window to. They are also used as the minimum size when
        'set_dimensions' is called.

        :param dimensions: The new minimum dimension for the window.

        """
        self.minimum_dimensions = (min(self.ui_container.rect.width,
                                       int(dimensions[0])),
                                   min(self.ui_container.rect.height,
                                       int(dimensions[1])))

        if ((self.rect.width < self.minimum_dimensions[0])
                or (self.rect.height < self.minimum_dimensions[1])):
            new_width = max(self.minimum_dimensions[0], self.rect.width)
            new_height = max(self.minimum_dimensions[1], self.rect.height)
            self.set_dimensions((new_width, new_height))

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

        :param dimensions: The new dimensions to set.

        """
        # clamp to minimum dimensions and container size
        dimensions = (min(self.ui_container.rect.width,
                          max(self.minimum_dimensions[0], int(dimensions[0]))),
                      min(self.ui_container.rect.height,
                          max(self.minimum_dimensions[1], int(dimensions[1]))))

        # 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)

        if self._window_root_container is not None:
            new_container_dimensions = (self.relative_rect.width -
                                        (2 * self.shadow_width),
                                        self.relative_rect.height -
                                        (2 * self.shadow_width))
            if new_container_dimensions != self._window_root_container.relative_rect.size:
                self._window_root_container.set_dimensions(
                    new_container_dimensions)
                container_pos = (self.relative_rect.x + self.shadow_width,
                                 self.relative_rect.y + self.shadow_width)
                self._window_root_container.set_relative_position(
                    container_pos)

    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)

        if self._window_root_container is not None:
            container_pos = (self.relative_rect.x + self.shadow_width,
                             self.relative_rect.y + self.shadow_width)
            self._window_root_container.set_relative_position(container_pos)

    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)

        if self._window_root_container is not None:
            container_pos = (self.relative_rect.x + self.shadow_width,
                             self.relative_rect.y + self.shadow_width)
            self._window_root_container.set_relative_position(container_pos)

    def process_event(self, event: pygame.event.Event) -> bool:
        """
        Handles resizing & closing windows. Gives UI Windows access to pygame events. Derived
        windows should super() call this class if they implement their own process_event method.

        :param event: The event to process.

        :return bool: Return True if this element should consume this event and not pass it to the
                      rest of the UI.

        """
        consumed_event = False

        if self.is_blocking and event.type == pygame.MOUSEBUTTONDOWN:
            consumed_event = True

        if (self is not None and event.type == pygame.MOUSEBUTTONDOWN
                and event.button in [
                    pygame.BUTTON_LEFT, pygame.BUTTON_MIDDLE,
                    pygame.BUTTON_RIGHT
                ]):
            scaled_mouse_pos = self.ui_manager.calculate_scaled_mouse_position(
                event.pos)

            edge_hovered = (self.edge_hovering[0] or self.edge_hovering[1]
                            or self.edge_hovering[2] or self.edge_hovering[3])
            if (self.is_enabled and event.button == pygame.BUTTON_LEFT
                    and edge_hovered):
                self.resizing_mode_active = True
                self.start_resize_point = scaled_mouse_pos
                self.start_resize_rect = self.rect.copy()
                consumed_event = True
            elif self.hover_point(scaled_mouse_pos[0], scaled_mouse_pos[1]):
                consumed_event = True

        if (self is not None and event.type == pygame.MOUSEBUTTONUP
                and event.button == pygame.BUTTON_LEFT
                and self.resizing_mode_active):
            self.resizing_mode_active = False

        if (event.type == pygame.USEREVENT
                and event.user_type == UI_BUTTON_PRESSED
                and event.ui_element == self.close_window_button):
            self.kill()

        return consumed_event

    def check_clicked_inside_or_blocking(self,
                                         event: pygame.event.Event) -> bool:
        """
        A quick event check outside of the normal event processing so that this window is brought
        to the front of the window stack if we click on any of the elements contained within it.

        :param event: The event to check.

        :return: returns True if the event represents a click inside this window or the window
                 is blocking.

        """
        consumed_event = False
        if self.is_blocking and event.type == pygame.MOUSEBUTTONDOWN:
            consumed_event = True

        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]) or (
                    self.edge_hovering[0] or self.edge_hovering[1]
                    or self.edge_hovering[2] or self.edge_hovering[3]):
                if self.is_enabled and self.bring_to_front_on_focused:
                    self.window_stack.move_window_to_front(self)
                consumed_event = True

        return consumed_event

    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 window'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)

        # This is needed to keep the window in sync with the container after adding elements to it
        if self._window_root_container.layer_thickness != self.layer_thickness:
            self.layer_thickness = self._window_root_container.layer_thickness
        if self.title_bar is not None:
            if self.title_bar.held:
                mouse_x, mouse_y = self.ui_manager.get_mouse_position()
                if not self.grabbed_window:
                    self.window_stack.move_window_to_front(self)
                    self.grabbed_window = True
                    self.starting_grab_difference = (mouse_x - self.rect.x,
                                                     mouse_y - self.rect.y)

                current_grab_difference = (mouse_x - self.rect.x,
                                           mouse_y - self.rect.y)

                adjustment_required = (current_grab_difference[0] -
                                       self.starting_grab_difference[0],
                                       current_grab_difference[1] -
                                       self.starting_grab_difference[1])

                self.set_relative_position(
                    (self.relative_rect.x + adjustment_required[0],
                     self.relative_rect.y + adjustment_required[1]))
            else:
                self.grabbed_window = False

        if self.resizing_mode_active:
            self._update_drag_resizing()

    def _update_drag_resizing(self):
        """
        Re-sizes a window that is being dragged around its the edges by the mouse.

        """
        x_pos = self.rect.left
        y_pos = self.rect.top
        x_dimension = self.rect.width
        y_dimension = self.rect.height
        mouse_x, mouse_y = self.ui_manager.get_mouse_position()
        x_diff = mouse_x - self.start_resize_point[0]
        y_diff = mouse_y - self.start_resize_point[1]
        if y_dimension >= self.minimum_dimensions[1]:
            y_pos = self.start_resize_rect.y
            y_dimension = self.start_resize_rect.height
            if self.edge_hovering[1]:
                y_dimension = self.start_resize_rect.height - y_diff
                y_pos = self.start_resize_rect.y + y_diff
            elif self.edge_hovering[3]:
                y_dimension = self.start_resize_rect.height + y_diff

            if y_dimension < self.minimum_dimensions[1]:
                if y_diff > 0:
                    y_pos = self.rect.bottom - self.minimum_dimensions[1]
                else:
                    y_pos = self.rect.top
        if x_dimension >= self.minimum_dimensions[0]:
            x_pos = self.start_resize_rect.x
            x_dimension = self.start_resize_rect.width
            if self.edge_hovering[0]:
                x_dimension = self.start_resize_rect.width - x_diff
                x_pos = self.start_resize_rect.x + x_diff
            elif self.edge_hovering[2]:
                x_dimension = self.start_resize_rect.width + x_diff

            if x_dimension < self.minimum_dimensions[0]:
                if x_diff > 0:
                    x_pos = self.rect.right - self.minimum_dimensions[0]
                else:
                    x_pos = self.rect.left
        x_dimension = max(self.minimum_dimensions[0],
                          min(self.ui_container.rect.width, x_dimension))
        y_dimension = max(self.minimum_dimensions[1],
                          min(self.ui_container.rect.height, y_dimension))
        self.set_position((x_pos, y_pos))
        self.set_dimensions((x_dimension, y_dimension))

    def get_container(self) -> IUIContainerInterface:
        """
        Returns the container that should contain all the UI elements in this window.

        :return UIContainer: The window's container.

        """
        return self.window_element_container

    def can_hover(self) -> bool:
        """
        Called to test if this window can be hovered.
        """
        return not (self.resizing_mode_active or
                    (self.title_bar is not None and self.title_bar.held))

    # noinspection PyUnusedLocal
    def check_hover(self, time_delta: float,
                    hovered_higher_element: bool) -> bool:
        """
        For the window the only hovering we care about is the edges if this is a resizable window.

        :param time_delta: time passed in seconds between one call to this method and the next.
        :param hovered_higher_element: Have we already hovered an element/window above this one.

        """
        hovered = False
        if not self.resizing_mode_active:
            self.edge_hovering = [False, False, False, False]
        if self.alive() and self.can_hover(
        ) and not hovered_higher_element and self.resizable:
            mouse_x, mouse_y = self.ui_manager.get_mouse_position()

            # Build a temporary rect just a little bit larger than our container rect.
            resize_rect = pygame.Rect(
                self._window_root_container.rect.left - 4,
                self._window_root_container.rect.top - 4,
                self._window_root_container.rect.width + 8,
                self._window_root_container.rect.height + 8)
            if resize_rect.collidepoint(mouse_x, mouse_y):
                if resize_rect.right > mouse_x > resize_rect.right - 6:
                    self.edge_hovering[2] = True
                    hovered = True

                if resize_rect.left + 6 > mouse_x > resize_rect.left:
                    self.edge_hovering[0] = True
                    hovered = True

                if resize_rect.bottom > mouse_y > resize_rect.bottom - 6:
                    self.edge_hovering[3] = True
                    hovered = True

                if resize_rect.top + 6 > mouse_y > resize_rect.top:
                    self.edge_hovering[1] = True
                    hovered = True
        elif self.resizing_mode_active:
            hovered = True

        if self.is_blocking:
            hovered = True

        if hovered:
            hovered_higher_element = True
            self.hovered = True
        else:
            self.hovered = False

        return hovered_higher_element

    def get_top_layer(self) -> int:
        """
        Returns the 'highest' layer used by this window so that we can correctly place other
        windows on top of it.

        :return: The top layer for this window as a number (greater numbers are higher layers).

        """
        return self._layer + self.layer_thickness

    def change_layer(self, new_layer: int):
        """
        Move this window, and it's contents, to a new layer in the UI.

        :param new_layer: The layer to move to.

        """
        if new_layer != self._layer:
            super().change_layer(new_layer)
            if self._window_root_container is not None:
                self._window_root_container.change_layer(new_layer)

                if self._window_root_container.layer_thickness != self.layer_thickness:
                    self.layer_thickness = self._window_root_container.layer_thickness

    def kill(self):
        """
        Overrides the basic kill() method of a pygame sprite so that we also kill all the UI
        elements in this window, and remove if from the window stack.
        """
        window_close_event = pygame.event.Event(
            pygame.USEREVENT, {
                'user_type': UI_WINDOW_CLOSE,
                'ui_element': self,
                'ui_object_id': self.most_specific_combined_id
            })
        pygame.event.post(window_close_event)

        self.window_stack.remove_window(self)
        self._window_root_container.kill()
        super().kill()

    def rebuild(self):
        """
        Rebuilds the window when the theme has changed.

        """
        if self._window_root_container is None:
            self._window_root_container = UIContainer(
                pygame.Rect(
                    self.relative_rect.x + self.shadow_width,
                    self.relative_rect.y + self.shadow_width,
                    self.relative_rect.width - (2 * self.shadow_width),
                    self.relative_rect.height - (2 * self.shadow_width)),
                manager=self.ui_manager,
                starting_height=1,
                is_window_root_container=True,
                container=None,
                parent_element=self,
                object_id="#window_root_container",
                visible=self.visible)
        if self.window_element_container is None:
            window_container_rect = pygame.Rect(
                self.border_width, self.title_bar_height,
                (self._window_root_container.relative_rect.width -
                 (2 * self.border_width)),
                (self._window_root_container.relative_rect.height -
                 (self.title_bar_height + self.border_width)))
            self.window_element_container = UIContainer(
                window_container_rect,
                self.ui_manager,
                starting_height=0,
                container=self._window_root_container,
                parent_element=self,
                object_id="#window_element_container",
                anchors={
                    'top': 'top',
                    'bottom': 'bottom',
                    'left': 'left',
                    'right': 'right'
                })

        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.set_image(self.drawable_shape.get_fresh_surface())

        self.set_dimensions(self.relative_rect.size)

        if self.window_element_container is not None:
            element_container_width = (
                self._window_root_container.relative_rect.width -
                (2 * self.border_width))
            element_container_height = (
                self._window_root_container.relative_rect.height -
                (self.title_bar_height + self.border_width))
            self.window_element_container.set_dimensions(
                (element_container_width, element_container_height))
            self.window_element_container.set_relative_position(
                (self.border_width, self.title_bar_height))

            if self.enable_title_bar:
                if self.title_bar is not None:
                    self.title_bar.set_dimensions(
                        (self._window_root_container.relative_rect.width -
                         self.title_bar_close_button_width,
                         self.title_bar_height))
                else:
                    title_bar_width = (
                        self._window_root_container.relative_rect.width -
                        self.title_bar_close_button_width)
                    self.title_bar = UIButton(
                        relative_rect=pygame.Rect(0, 0, title_bar_width,
                                                  self.title_bar_height),
                        text=self.window_display_title,
                        manager=self.ui_manager,
                        container=self._window_root_container,
                        parent_element=self,
                        object_id='#title_bar',
                        anchors={
                            'top': 'top',
                            'bottom': 'top',
                            'left': 'left',
                            'right': 'right'
                        })
                    self.title_bar.set_hold_range((100, 100))

                if self.enable_close_button:
                    if self.close_window_button is not None:
                        close_button_pos = (-self.title_bar_close_button_width,
                                            0)
                        self.close_window_button.set_dimensions(
                            (self.title_bar_close_button_width,
                             self.title_bar_height))
                        self.close_window_button.set_relative_position(
                            close_button_pos)
                    else:
                        close_rect = pygame.Rect(
                            (-self.title_bar_close_button_width, 0),
                            (self.title_bar_close_button_width,
                             self.title_bar_height))
                        self.close_window_button = UIButton(
                            relative_rect=close_rect,
                            text='╳',
                            manager=self.ui_manager,
                            container=self._window_root_container,
                            parent_element=self,
                            object_id='#close_button',
                            anchors={
                                'top': 'top',
                                'bottom': 'top',
                                'left': 'right',
                                'right': 'right'
                            })

                else:
                    if self.close_window_button is not None:
                        self.close_window_button.kill()
                        self.close_window_button = None
            else:
                if self.title_bar is not None:
                    self.title_bar.kill()
                    self.title_bar = None
                if self.close_window_button is not None:
                    self.close_window_button.kill()
                    self.close_window_button = None

    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

        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': 15,
                'shape_corner_radius': 2
        }):
            has_any_changed = True

        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_title_bar_theming_changed():
            has_any_changed = True

        if has_any_changed:
            self.rebuild()

    def _check_title_bar_theming_changed(self):
        """
        Check to see if any theming parameters for the title bar have changed.

        :return: True if any of the theming parameters have changed.

        """
        has_any_changed = False

        def parse_to_bool(str_data: str):
            return bool(int(str_data))

        if self._check_misc_theme_data_changed(
                attribute_name='enable_title_bar',
                default_value=True,
                casting_func=parse_to_bool):
            has_any_changed = True

        if self.enable_title_bar:

            if self._check_misc_theme_data_changed(
                    attribute_name='title_bar_height',
                    default_value=28,
                    casting_func=int):
                has_any_changed = True
                self.title_bar_close_button_width = self.title_bar_height

            if self._check_misc_theme_data_changed(
                    attribute_name='enable_close_button',
                    default_value=True,
                    casting_func=parse_to_bool):
                has_any_changed = True

            if not self.enable_close_button:
                self.title_bar_close_button_width = 0

        else:
            self.title_bar_height = 0
        return has_any_changed

    def should_use_window_edge_resize_cursor(self) -> bool:
        """
        Returns true if this window is in a state where we should display one of the resizing
        cursors

        :return: True if a resizing cursor is needed.
        """
        return (self.hovered or self.resizing_mode_active) and any(
            self.edge_hovering)

    def get_hovering_edge_id(self) -> str:
        """
        Gets the ID of the combination of edges we are hovering for use by the cursor system.

        :return: a string containing the edge combination ID (e.g. xy,yx,xl,xr,yt,yb)

        """
        if ((self.edge_hovering[0] and self.edge_hovering[1])
                or (self.edge_hovering[2] and self.edge_hovering[3])):
            return 'xy'
        elif ((self.edge_hovering[0] and self.edge_hovering[3])
              or (self.edge_hovering[2] and self.edge_hovering[1])):
            return 'yx'
        elif self.edge_hovering[0]:
            return 'xl'
        elif self.edge_hovering[2]:
            return 'xr'
        elif self.edge_hovering[3]:
            return 'yb'
        else:
            return 'yt'

    def on_moved_to_front(self):
        """
        Called when a window is moved to the front of the stack.
        """
        window_front_event = pygame.event.Event(
            pygame.USEREVENT, {
                'user_type': UI_WINDOW_MOVED_TO_FRONT,
                'ui_element': self,
                'ui_object_id': self.most_specific_combined_id
            })
        pygame.event.post(window_front_event)

    def set_display_title(self, new_title: str):
        """
        Set the title of the window.

        :param new_title: The title to set.
        """
        self.window_display_title = new_title
        self.title_bar.set_text(self.window_display_title)

    def disable(self):
        """
        Disables the window and it's contents so it is no longer interactive.
        """
        if self.is_enabled:
            self.is_enabled = False
            self._window_root_container.disable()

    def enable(self):
        """
        Enables the window and it's contents so it is interactive again.
        """
        if not self.is_enabled:
            self.is_enabled = True
            self._window_root_container.enable()

    def show(self):
        """
        In addition to the base UIElement.show() - show the _window_root_container which will
        propagate and show all the children.
        """
        super().show()

        self._window_root_container.show()

    def hide(self):
        """
        In addition to the base UIElement.hide() - hide the _window_root_container which will
        propagate and hide all the children.
        """
        super().hide()

        self._window_root_container.hide()
Example #9
0
class UITextEntryLine(UIElement):
    """
    A GUI element for text entry from a keyboard, on a single line. The element supports
    the standard copy and paste keyboard shortcuts CTRL+V, CTRL+C & CTRL+X as well as CTRL+A.

    There are methods that allow the entry element to restrict the characters that can be input
    into the text box

    The height of the text entry line element will be determined by the font used rather than
    the standard method for UIElements of just using the height of the input rectangle.

    :param relative_rect: A rectangle describing the position and width of the text entry element.
    :param manager: The UIManager that manages this element.
    :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.
    """

    _number_character_set = {'en': ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']}

    # excluding these characters won't ensure that user entered text is a valid filename but they
    # can help reduce the problems that input will leave you with.
    _forbidden_file_path_characters = {'en': ['<', '>', ':', '"', '/',
                                              '\\', '|', '?', '*', '\0', '.']}

    _alphabet_characters_lower = {'en': ['a', 'b', 'c', 'd', 'e', 'f', 'g',
                                         'h', 'i', 'j', 'k', 'l', 'm', 'n',
                                         'o', 'p', 'q', 'r', 's', 't', 'u',
                                         'v', 'w', 'x', 'y', 'z']}
    _alphabet_characters_upper: Dict[str, List[str]] = {
        'en': [char.upper() for char in _alphabet_characters_lower['en']],
        'ja': [],  # no upper case in japanese
        'zh': []}  # no upper case in chinese

    _alphabet_characters_all = {'en': _alphabet_characters_lower['en'] +
                                _alphabet_characters_upper['en']}

    _alpha_numeric_characters = {'en': (_alphabet_characters_all['en'] +
                                        _number_character_set['en'])}

    def __init__(self,
                 relative_rect: pygame.Rect,
                 manager: IUIManagerInterface,
                 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=1, layer_thickness=1,
                         anchors=anchors, visible=visible)

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

        self.text = ""
        self.is_text_hidden = False
        self.hidden_text_char = '●'

        # theme font
        self.font: Optional[pygame.freetype.Font] = None

        self.shadow_width = None
        self.border_width = None
        self.padding = None
        self.text_surface = None
        self.cursor = None
        self.background_and_border = None
        self.text_image_rect = None

        # colours from theme
        self.background_colour = None
        self.text_colour = None
        self.selected_text_colour = None
        self.selected_bg_colour = None
        self.border_colour = None
        self.disabled_background_colour = None
        self.disabled_border_colour = None
        self.disabled_text_colour = None
        self.text_cursor_colour = None
        self.padding = (0, 0)

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

        # input timings - I expect nobody really wants to mess with these that much
        # ideally we could populate from the os settings but that sounds like a headache
        self.key_repeat = 0.5
        self.cursor_blink_delay_after_moving_acc = 0.0
        self.cursor_blink_delay_after_moving = 1.0
        self.blink_cursor_time_acc = 0.0
        self.blink_cursor_time = 0.4

        self.double_click_timer = self.ui_manager.get_double_click_time() + 1.0

        self.start_text_offset = 0
        self.edit_position = 0
        self._select_range = [0, 0]
        self.selection_in_progress = False

        self.cursor_on = False
        self.cursor_has_moved_recently = False
        self.text_entered = False  # Reset when enter key up or focus lost

        # restrictions on text input
        self.allowed_characters: Optional[List[str]] = None
        self.forbidden_characters: Optional[List[str]] = None
        self.length_limit: Optional[int] = None

        self.rebuild_from_changed_theme_data()

    @property
    def select_range(self):
        """
        The selected range for this text. A tuple containing the start
        and end indexes of the current selection.

        Made into a property to keep it synchronised with the underlying drawable shape's
        representation.
        """
        return self._select_range

    @select_range.setter
    def select_range(self, value):
        self._select_range = value
        start_select = min(self._select_range[0], self._select_range[1])
        end_select = max(self._select_range[0], self._select_range[1])
        if self.drawable_shape is not None:
            self.drawable_shape.text_box_layout.set_text_selection(start_select, end_select)
            self.drawable_shape.apply_active_text_changes()

    def set_text_hidden(self, is_hidden=True):
        """
        Passing in True will hide text typed into the text line, replacing it with ●
        characters and also disallow copying the text into the clipboard. It is designed
        for basic 'password box' usage.

        :param is_hidden: Can be set to True or False. Defaults to True because
                          if you are calling this you likely want a password box with no fuss.
                          Set it back to False if you want to un-hide the text (e.g.
                          for one of those 'Show my password' buttons).
        """

        self.is_text_hidden = is_hidden
        self.rebuild()

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

        """

        display_text = self.text
        if self.is_text_hidden:
            # test if self.hidden_text_char is supported by font here
            if self.font.get_metrics(self.hidden_text_char)[0] is None:
                self.hidden_text_char = '*'
                if self.font.get_metrics(self.hidden_text_char)[0] is None:
                    self.hidden_text_char = '.'
                    if self.font.get_metrics(self.hidden_text_char)[0] is None:
                        raise ValueError('Selected font for UITextEntryLine does not contain '
                                         '●, * or . characters used for hidden text. Please choose'
                                         'a different font for this element')
                display_text = self.hidden_text_char*len(self.text)

        theming_parameters = {'normal_bg': self.background_colour,
                              'normal_text': self.text_colour,
                              'normal_text_shadow': pygame.Color('#000000'),
                              'normal_border': self.border_colour,
                              'disabled_bg': self.disabled_background_colour,
                              'disabled_text': self.disabled_text_colour,
                              'disabled_text_shadow': pygame.Color('#000000'),
                              'disabled_border': self.disabled_border_colour,
                              'selected_text': self.selected_text_colour,
                              'text_cursor_colour': self.text_cursor_colour,
                              'border_width': self.border_width,
                              'shadow_width': self.shadow_width,
                              'font': self.font,
                              'text': display_text,
                              'text_width': -1,
                              'text_horiz_alignment': 'left',
                              'text_vert_alignment': 'centre',
                              'text_horiz_alignment_padding': self.padding[0],
                              'text_vert_alignment_padding': self.padding[1],
                              'shape_corner_radius': self.shape_corner_radius}

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

        if self.drawable_shape is not None:
            self.set_image(self.drawable_shape.get_fresh_surface())
            if self.rect.width == -1 or self.rect.height == -1:
                self.set_dimensions(self.drawable_shape.containing_rect.size)

    def set_text_length_limit(self, limit: int):
        """
        Allows a character limit to be set on the text entry element. By default there is no
        limit on the number of characters that can be entered.

        :param limit: The character limit as an integer.

        """
        self.length_limit = limit

    def get_text(self) -> str:
        """
        Gets the text in the entry line element.

        :return: A string.

        """
        return self.text

    def set_text(self, text: str):
        """
        Allows the text displayed in the text entry element to be set via code. Useful for
        setting an initial or existing value that is able to be edited.

        The string to set must be valid for the text entry element for this to work.

        :param text: The text string to set.

        """
        if self.validate_text_string(text):
            within_length_limit = True
            if self.length_limit is not None and len(text) > self.length_limit:
                within_length_limit = False
            if within_length_limit:
                self.text = text
                self.edit_position = len(self.text)
                display_text = self.text
                if self.is_text_hidden:
                    display_text = self.hidden_text_char * len(self.text)
                if self.drawable_shape is not None:
                    self.drawable_shape.set_text(display_text)
                    self.drawable_shape.text_box_layout.set_cursor_position(self.edit_position)
                    self.drawable_shape.apply_active_text_changes()
            else:
                warnings.warn("Tried to set text string that is too long on text entry element")
        else:
            warnings.warn("Tried to set text string with invalid characters on text entry element")

    def redraw(self):
        """
        Redraws the entire text entry element onto the underlying sprite image. Usually called
        when the displayed text has been edited or changed in some fashion.
        """
        if self.drawable_shape is not None:
            self.drawable_shape.text_box_layout.set_cursor_position(self.edit_position)
            self.drawable_shape.redraw_all_states()

    def update(self, time_delta: float):
        """
        Called every update loop of our UI Manager. Largely handles text drag selection and
        making sure our edit cursor blinks on and off.

        :param time_delta: The time in seconds between this update method call and the previous one.

        """
        super().update(time_delta)

        if not self.alive():
            return
        scaled_mouse_pos = self.ui_manager.calculate_scaled_mouse_position(pygame.mouse.get_pos())
        if self.hover_point(scaled_mouse_pos[0], scaled_mouse_pos[1]):
            self.ui_manager.set_text_input_hovered(True)
        else:
            self.ui_manager.set_text_input_hovered(False)
        if self.double_click_timer < self.ui_manager.get_double_click_time():
            self.double_click_timer += time_delta
        if self.selection_in_progress:
            mouse_pos = self.ui_manager.get_mouse_position()
            drawable_shape_space_click = (mouse_pos[0] - self.rect.left,
                                          mouse_pos[1] - self.rect.top)
            if self.drawable_shape is not None:
                self.drawable_shape.text_box_layout.set_cursor_from_click_pos(
                    drawable_shape_space_click)
                self.drawable_shape.apply_active_text_changes()
                select_end_pos = self.drawable_shape.text_box_layout.get_cursor_index()
                new_range = [self.select_range[0], select_end_pos]

                if new_range[0] != self.select_range[0] or new_range[1] != self.select_range[1]:
                    self.select_range = [new_range[0], new_range[1]]

                    self.edit_position = self.select_range[1]
                    self.cursor_has_moved_recently = True

        if self.cursor_has_moved_recently:
            self.cursor_has_moved_recently = False
            self.cursor_blink_delay_after_moving_acc = 0.0
            self.cursor_on = True
            if self.drawable_shape is not None:
                self.drawable_shape.text_box_layout.set_cursor_position(self.edit_position)
                self.drawable_shape.toggle_text_cursor()
                self.drawable_shape.apply_active_text_changes()

        if self.cursor_blink_delay_after_moving_acc > self.cursor_blink_delay_after_moving:
            if self.blink_cursor_time_acc >= self.blink_cursor_time:
                self.blink_cursor_time_acc = 0.0
                if self.cursor_on:
                    self.cursor_on = False
                    if self.drawable_shape is not None:
                        self.drawable_shape.toggle_text_cursor()
                elif self.is_focused:
                    self.cursor_on = True
                    if self.drawable_shape is not None:
                        self.drawable_shape.toggle_text_cursor()
            else:
                self.blink_cursor_time_acc += time_delta
        else:
            self.cursor_blink_delay_after_moving_acc += time_delta

    def unfocus(self):
        """
        Called when this element is no longer the current focus.
        """
        super().unfocus()
        pygame.key.set_repeat(0)
        self.select_range = [0, 0]
        self.edit_position = 0
        self.cursor_on = False
        self.text_entered = False
        self.redraw()

    def focus(self):
        """
        Called when we 'select focus' on this element. In this case it sets up the keyboard to
        repeat held key presses, useful for natural feeling keyboard input.
        """
        super().focus()
        pygame.key.set_repeat(500, 25)

    def process_event(self, event: pygame.event.Event) -> bool:
        """
        Allows the text entry box to react to input events, which is it's primary function.
        The entry element reacts to various types of mouse clicks (double click selecting words,
        drag select), keyboard combos (CTRL+C, CTRL+V, CTRL+X, CTRL+A), individual editing keys
        (Backspace, Delete, Left & Right arrows) and other keys for inputting letters, symbols
        and numbers.

        :param event: The current event to consider reacting to.

        :return: Returns True if we've done something with the input event.

        """
        consumed_event = False

        initial_text_state = self.text
        if self._process_mouse_button_event(event):
            consumed_event = True
        if self.is_enabled and self.is_focused and event.type == pygame.KEYDOWN:
            if self._process_keyboard_shortcut_event(event):
                consumed_event = True
            elif self._process_action_key_event(event):
                consumed_event = True
            elif self._process_text_entry_key(event):
                consumed_event = True

        if self.is_enabled and self.is_focused and event.type == pygame.KEYUP:
            if event.key == pygame.K_RETURN or event.key == pygame.K_KP_ENTER:
                self.text_entered = False  # reset text input entry

        if self.text != initial_text_state:
            # old event to be removed in 0.8.0
            event_data = {'user_type': OldType(UI_TEXT_ENTRY_CHANGED),
                          'text': self.text,
                          '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 = {'text': self.text,
                          'ui_element': self,
                          'ui_object_id': self.most_specific_combined_id}
            pygame.event.post(pygame.event.Event(UI_TEXT_ENTRY_CHANGED, event_data))
        return consumed_event

    def _process_text_entry_key(self, event: pygame.event.Event) -> bool:
        """
        Process key input that can be added to the text entry text.

        :param event: The event to process.

        :return: True if consumed.
        """
        consumed_event = False
        within_length_limit = True
        if (self.length_limit is not None
                and (len(self.text) -
                     abs(self.select_range[0] -
                         self.select_range[1])) >= self.length_limit):
            within_length_limit = False
        if within_length_limit and hasattr(event, 'unicode') and self.font is not None:
            character = event.unicode
            char_metrics = self.font.get_metrics(character)
            if len(char_metrics) > 0 and char_metrics[0] is not None:
                valid_character = True
                if (self.allowed_characters is not None and
                        character not in self.allowed_characters):
                    valid_character = False
                if (self.forbidden_characters is not None and
                        character in self.forbidden_characters):
                    valid_character = False
                if valid_character:
                    if abs(self.select_range[0] - self.select_range[1]) > 0:
                        low_end = min(self.select_range[0], self.select_range[1])
                        high_end = max(self.select_range[0], self.select_range[1])
                        self.text = self.text[:low_end] + character + self.text[high_end:]

                        if self.drawable_shape is not None:
                            self.drawable_shape.set_text(self.text)
                        self.edit_position = low_end + 1
                        self.select_range = [0, 0]
                    else:
                        start_str = self.text[:self.edit_position]
                        end_str = self.text[self.edit_position:]
                        self.text = start_str + character + end_str
                        display_character = character
                        if self.is_text_hidden:
                            display_character = self.hidden_text_char
                        if self.drawable_shape is not None:
                            self.drawable_shape.insert_text(display_character, self.edit_position)

                        self.edit_position += 1
                    self.cursor_has_moved_recently = True
                    consumed_event = True
        return consumed_event

    def _process_action_key_event(self, event: pygame.event.Event) -> bool:
        """
        Check if event is one of the keys that triggers an action like deleting, or moving
        the edit position.

        :param event: The event to check.

        :return: True if event is consumed.

        """
        consumed_event = False
        if ((event.key == pygame.K_RETURN or event.key == pygame.K_KP_ENTER)
                and not self.text_entered):
            # old event - to be removed in 0.8.0
            event_data = {'user_type': OldType(UI_TEXT_ENTRY_FINISHED),
                          'text': self.text,
                          '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 = {'text': self.text,
                          'ui_element': self,
                          'ui_object_id': self.most_specific_combined_id}
            pygame.event.post(pygame.event.Event(UI_TEXT_ENTRY_FINISHED, event_data))
            consumed_event = True
            self.text_entered = True
        elif event.key == pygame.K_BACKSPACE:
            if abs(self.select_range[0] - self.select_range[1]) > 0:
                if self.drawable_shape is not None:
                    self.drawable_shape.text_box_layout.delete_selected_text()
                    self.drawable_shape.apply_active_text_changes()
                low_end = min(self.select_range[0], self.select_range[1])
                high_end = max(self.select_range[0], self.select_range[1])
                self.text = self.text[:low_end] + self.text[high_end:]
                self.edit_position = low_end
                self.select_range = [0, 0]
                self.cursor_has_moved_recently = True
                if self.drawable_shape is not None:
                    self.drawable_shape.text_box_layout.set_cursor_position(self.edit_position)
                    self.drawable_shape.apply_active_text_changes()
            elif self.edit_position > 0 and self.font is not None:
                if self.start_text_offset > 0:
                    self.start_text_offset -= self.font.get_rect(
                        self.text[self.edit_position - 1]).width
                self.text = self.text[:self.edit_position - 1] + self.text[self.edit_position:]
                self.edit_position -= 1
                self.cursor_has_moved_recently = True
                if self.drawable_shape is not None:
                    self.drawable_shape.text_box_layout.backspace_at_cursor()
                    self.drawable_shape.text_box_layout.set_cursor_position(self.edit_position)
                    self.drawable_shape.apply_active_text_changes()
            consumed_event = True
        elif event.key == pygame.K_DELETE:
            if abs(self.select_range[0] - self.select_range[1]) > 0:
                if self.drawable_shape is not None:
                    self.drawable_shape.text_box_layout.delete_selected_text()
                    self.drawable_shape.apply_active_text_changes()
                low_end = min(self.select_range[0], self.select_range[1])
                high_end = max(self.select_range[0], self.select_range[1])
                self.text = self.text[:low_end] + self.text[high_end:]
                self.edit_position = low_end
                self.select_range = [0, 0]
                self.cursor_has_moved_recently = True
                if self.drawable_shape is not None:
                    self.drawable_shape.text_box_layout.set_cursor_position(self.edit_position)
                    self.drawable_shape.apply_active_text_changes()
            elif self.edit_position < len(self.text):
                self.text = self.text[:self.edit_position] + self.text[self.edit_position + 1:]
                self.edit_position = self.edit_position
                self.cursor_has_moved_recently = True
                if self.drawable_shape is not None:
                    self.drawable_shape.text_box_layout.delete_at_cursor()
                    self.drawable_shape.apply_active_text_changes()
            consumed_event = True
        elif self._process_edit_pos_move_key(event):
            consumed_event = True

        return consumed_event

    def _process_edit_pos_move_key(self, event: pygame.event.Event) -> bool:
        """
        Process an action key that is moving the cursor edit position.

        :param event: The event to process.

        :return: True if event is consumed.

        """
        consumed_event = False
        if event.key == pygame.K_LEFT:
            if abs(self.select_range[0] - self.select_range[1]) > 0:
                self.edit_position = min(self.select_range[0], self.select_range[1])
                self.select_range = [0, 0]
                self.cursor_has_moved_recently = True
            elif self.edit_position > 0:
                self.edit_position -= 1
                self.cursor_has_moved_recently = True
            consumed_event = True
        elif event.key == pygame.K_RIGHT:
            if abs(self.select_range[0] - self.select_range[1]) > 0:
                self.edit_position = max(self.select_range[0], self.select_range[1])
                self.select_range = [0, 0]
                self.cursor_has_moved_recently = True
            elif self.edit_position < len(self.text):
                self.edit_position += 1
                self.cursor_has_moved_recently = True
            consumed_event = True
        return consumed_event

    def _process_keyboard_shortcut_event(self, event: pygame.event.Event) -> bool:
        """
        Check if event is one of the CTRL key keyboard shortcuts.

        :param event: event to process.

        :return: True if event consumed.

        """
        consumed_event = False
        if event.key == pygame.K_a and event.mod & pygame.KMOD_CTRL:
            self.select_range = [0, len(self.text)]
            self.edit_position = len(self.text)
            self.cursor_has_moved_recently = True
            consumed_event = True
        elif event.key == pygame.K_x and event.mod & pygame.KMOD_CTRL and not self.is_text_hidden:
            if abs(self.select_range[0] - self.select_range[1]) > 0:
                low_end = min(self.select_range[0], self.select_range[1])
                high_end = max(self.select_range[0], self.select_range[1])
                clipboard_copy(self.text[low_end:high_end])
                if self.drawable_shape is not None:
                    self.drawable_shape.text_box_layout.delete_selected_text()
                    self.drawable_shape.apply_active_text_changes()
                self.edit_position = low_end
                self.text = self.text[:low_end] + self.text[high_end:]
                if self.drawable_shape is not None:
                    self.drawable_shape.text_box_layout.set_cursor_position(self.edit_position)
                    self.drawable_shape.apply_active_text_changes()
                self.select_range = [0, 0]
                self.cursor_has_moved_recently = True
                consumed_event = True
        elif event.key == pygame.K_c and event.mod & pygame.KMOD_CTRL and not self.is_text_hidden:
            if abs(self.select_range[0] - self.select_range[1]) > 0:
                low_end = min(self.select_range[0], self.select_range[1])
                high_end = max(self.select_range[0], self.select_range[1])
                clipboard_copy(self.text[low_end:high_end])
                consumed_event = True
        elif self._process_paste_event(event):
            consumed_event = True
        return consumed_event

    def _process_paste_event(self, event: pygame.event.Event) -> bool:
        """
        Process a paste shortcut event. (CTRL+ V)

        :param event: The event to process.

        :return: True if the event is consumed.

        """
        consumed_event = False
        if event.key == pygame.K_v and event.mod & pygame.KMOD_CTRL:
            new_text = clipboard_paste()
            if self.validate_text_string(new_text):
                if abs(self.select_range[0] - self.select_range[1]) > 0:
                    low_end = min(self.select_range[0], self.select_range[1])
                    high_end = max(self.select_range[0], self.select_range[1])
                    final_text = self.text[:low_end] + new_text + self.text[high_end:]
                    within_length_limit = True
                    if self.length_limit is not None and len(final_text) > self.length_limit:
                        within_length_limit = False
                    if within_length_limit:
                        self.text = final_text
                        if self.drawable_shape is not None:
                            self.drawable_shape.text_box_layout.delete_selected_text()
                            self.drawable_shape.apply_active_text_changes()
                        display_new_text = new_text
                        if self.is_text_hidden:
                            display_new_text = self.hidden_text_char * len(new_text)
                        if self.drawable_shape is not None:
                            self.drawable_shape.insert_text(display_new_text, low_end)
                        self.edit_position = low_end + len(new_text)
                        if self.drawable_shape is not None:
                            self.drawable_shape.text_box_layout.set_cursor_position(
                                self.edit_position)
                            self.drawable_shape.apply_active_text_changes()
                        self.select_range = [0, 0]
                        self.cursor_has_moved_recently = True
                elif len(new_text) > 0:
                    final_text = (self.text[:self.edit_position] +
                                  new_text +
                                  self.text[self.edit_position:])
                    within_length_limit = True
                    if self.length_limit is not None and len(final_text) > self.length_limit:
                        within_length_limit = False
                    if within_length_limit:
                        self.text = final_text
                        display_new_text = new_text
                        if self.is_text_hidden:
                            display_new_text = self.hidden_text_char * len(new_text)
                        if self.drawable_shape is not None:
                            self.drawable_shape.insert_text(display_new_text, self.edit_position)
                        self.edit_position += len(new_text)
                        if self.drawable_shape is not None:
                            self.drawable_shape.text_box_layout.set_cursor_position(
                                self.edit_position)
                            self.drawable_shape.apply_active_text_changes()
                        self.cursor_has_moved_recently = True
                consumed_event = True
        return consumed_event

    def _process_mouse_button_event(self, event: pygame.event.Event) -> bool:
        """
        Process a mouse button event.

        :param event: Event to process.

        :return: True if we consumed the mouse event.

        """
        consumed_event = False
        if event.type == pygame.MOUSEBUTTONDOWN and event.button == pygame.BUTTON_LEFT:

            scaled_mouse_pos = self.ui_manager.calculate_scaled_mouse_position(event.pos)
            if self.hover_point(scaled_mouse_pos[0], scaled_mouse_pos[1]):
                if self.is_enabled:
                    drawable_shape_space_click = (scaled_mouse_pos[0] - self.rect.left,
                                                  scaled_mouse_pos[1] - self.rect.top)
                    if self.drawable_shape is not None:
                        self.drawable_shape.text_box_layout.set_cursor_from_click_pos(
                            drawable_shape_space_click)
                        self.edit_position = self.drawable_shape.text_box_layout.get_cursor_index()
                        self.drawable_shape.apply_active_text_changes()
                    double_clicking = False
                    if self.double_click_timer < self.ui_manager.get_double_click_time():
                        if self._calculate_double_click_word_selection():
                            double_clicking = True

                    if not double_clicking:
                        self.select_range = [self.edit_position, self.edit_position]
                        self.cursor_has_moved_recently = True
                        self.selection_in_progress = True
                        self.double_click_timer = 0.0

                consumed_event = True
        if (event.type == pygame.MOUSEBUTTONUP and
                event.button == pygame.BUTTON_LEFT and
                self.selection_in_progress):
            scaled_mouse_pos = self.ui_manager.calculate_scaled_mouse_position(event.pos)
            if self.drawable_shape is not None:
                if self.drawable_shape.collide_point(scaled_mouse_pos):
                    consumed_event = True
                    drawable_shape_space_click = (scaled_mouse_pos[0] - self.rect.left,
                                                  scaled_mouse_pos[1] - self.rect.top)
                    self.drawable_shape.text_box_layout.set_cursor_from_click_pos(
                        drawable_shape_space_click)
                    new_edit_pos = self.drawable_shape.text_box_layout.get_cursor_index()
                    if new_edit_pos != self.edit_position:
                        self.edit_position = new_edit_pos
                        self.cursor_has_moved_recently = True
                        self.select_range = [self.select_range[0], self.edit_position]
                    self.drawable_shape.apply_active_text_changes()
            self.selection_in_progress = False
        return consumed_event

    def _calculate_double_click_word_selection(self):
        """
        If we double clicked on a word in the text, select that word.

        """
        if self.edit_position != self.select_range[0]:
            return False
        index = min(self.edit_position, len(self.text) - 1)
        if index > 0:
            char = self.text[index]
            # Check we clicked in the same place on a our second click.
            pattern = re.compile(r"[\w']+")
            while not pattern.match(char):
                index -= 1
                if index > 0:
                    char = self.text[index]
                else:
                    break
            while pattern.match(char):
                index -= 1
                if index > 0:
                    char = self.text[index]
                else:
                    break
            start_select_index = index + 1 if index > 0 else index
            index += 1
            char = self.text[index]
            while index < len(self.text) and pattern.match(char):
                index += 1
                if index < len(self.text):
                    char = self.text[index]
            end_select_index = index

            self.select_range = [start_select_index, end_select_index]
            self.edit_position = end_select_index
            self.cursor_has_moved_recently = True
            self.selection_in_progress = False
            return True
        else:
            return False

    def set_allowed_characters(self, allowed_characters: Union[str, List[str]]):
        """
        Sets a whitelist of characters that will be the only ones allowed in our text entry
        element. We can either set the list directly, or request one of the already existing
        lists by a string identifier. The currently supported lists for allowed characters are:

        - 'numbers'
        - 'letters'
        - 'alpha_numeric'

        :param allowed_characters: The characters to allow, either in a list form or one of the
                                   supported string ids.

        """
        if isinstance(allowed_characters, str):
            if allowed_characters == 'numbers':
                if self.ui_manager.get_locale() in UITextEntryLine._number_character_set:
                    self.allowed_characters = UITextEntryLine._number_character_set[
                        self.ui_manager.get_locale()]
                else:
                    self.allowed_characters = UITextEntryLine._number_character_set['en']
            elif allowed_characters == 'letters':
                if self.ui_manager.get_locale() in UITextEntryLine._alphabet_characters_all:
                    self.allowed_characters = UITextEntryLine._alphabet_characters_all[
                        self.ui_manager.get_locale()]
                else:
                    self.allowed_characters = UITextEntryLine._alphabet_characters_all['en']
            elif allowed_characters == 'alpha_numeric':
                if self.ui_manager.get_locale() in UITextEntryLine._alpha_numeric_characters:
                    self.allowed_characters = UITextEntryLine._alpha_numeric_characters[
                        self.ui_manager.get_locale()]
                else:
                    self.allowed_characters = UITextEntryLine._alpha_numeric_characters['en']
            else:
                warnings.warn('Trying to set allowed characters by type string, but no match: '
                              'did you mean to use a list?')

        else:
            self.allowed_characters = allowed_characters.copy()

    def set_forbidden_characters(self, forbidden_characters: Union[str, List[str]]):
        """
        Sets a blacklist of characters that will be banned from our text entry element.
        We can either set the list directly, or request one of the already existing lists by a
        string identifier. The currently supported lists for forbidden characters are:

        - 'numbers'
        - 'forbidden_file_path'

        :param forbidden_characters: The characters to forbid, either in a list form or one of
                                     the supported string ids.

        """
        if isinstance(forbidden_characters, str):
            if forbidden_characters == 'numbers':
                if self.ui_manager.get_locale() in UITextEntryLine._number_character_set:
                    self.forbidden_characters = UITextEntryLine._number_character_set[
                        self.ui_manager.get_locale()]
                else:
                    self.forbidden_characters = UITextEntryLine._number_character_set['en']
            elif forbidden_characters == 'forbidden_file_path':
                if self.ui_manager.get_locale() in UITextEntryLine._forbidden_file_path_characters:
                    self.forbidden_characters = UITextEntryLine._forbidden_file_path_characters[
                        self.ui_manager.get_locale()]
                else:
                    self.forbidden_characters = (
                        UITextEntryLine._forbidden_file_path_characters['en'])
            else:
                warnings.warn('Trying to set forbidden characters by type string, but no match: '
                              'did you mean to use a list?')

        else:
            self.forbidden_characters = forbidden_characters.copy()

    def validate_text_string(self, text_to_validate: str) -> bool:
        """
        Checks a string of text to see if any of it's characters don't meet the requirements of
        the allowed and forbidden character sets.

        :param text_to_validate: The text string to check.

        """
        is_valid = True
        if self.forbidden_characters is not None:
            for character in text_to_validate:
                if character in self.forbidden_characters:
                    is_valid = False

        if is_valid and self.allowed_characters is not None:
            for character in text_to_validate:
                if character not in self.allowed_characters:
                    is_valid = False

        return is_valid

    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

        font = self.ui_theme.get_font(self.combined_element_ids)
        if font != self.font:
            self.font = font
            has_any_changed = True

        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=(2, 2),
                                               casting_func=self.tuple_extract):
            has_any_changed = True

        if self._check_theme_colours_changed():
            has_any_changed = True

        if has_any_changed:
            self.rebuild()

    def _check_theme_colours_changed(self):
        """
        Check if any colours have changed in the theme.

        :return: colour has changed.

        """
        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
        text_colour = self.ui_theme.get_colour_or_gradient('normal_text', self.combined_element_ids)
        if text_colour != self.text_colour:
            self.text_colour = text_colour
            has_any_changed = True
        selected_text_colour = self.ui_theme.get_colour_or_gradient('selected_text',
                                                                    self.combined_element_ids)
        if selected_text_colour != self.selected_text_colour:
            self.selected_text_colour = selected_text_colour
            has_any_changed = True
        selected_bg_colour = self.ui_theme.get_colour_or_gradient('selected_bg',
                                                                  self.combined_element_ids)
        if selected_bg_colour != self.selected_bg_colour:
            self.selected_bg_colour = selected_bg_colour
            has_any_changed = True

        disabled_background_colour = self.ui_theme.get_colour_or_gradient('disabled_dark_bg',
                                                                          self.combined_element_ids)
        if disabled_background_colour != self.disabled_background_colour:
            self.disabled_background_colour = disabled_background_colour
            has_any_changed = True

        disabled_border_colour = self.ui_theme.get_colour_or_gradient('disabled_border',
                                                                      self.combined_element_ids)
        if disabled_border_colour != self.disabled_border_colour:
            self.disabled_border_colour = disabled_border_colour
            has_any_changed = True

        disabled_text_colour = self.ui_theme.get_colour_or_gradient('disabled_text',
                                                                    self.combined_element_ids)
        if disabled_text_colour != self.disabled_text_colour:
            self.disabled_text_colour = disabled_text_colour
            has_any_changed = True

        text_cursor_colour = self.ui_theme.get_colour_or_gradient('text_cursor',
                                                                  self.combined_element_ids)
        if text_cursor_colour != self.text_cursor_colour:
            self.text_cursor_colour = text_cursor_colour
            has_any_changed = True

        return has_any_changed

    def disable(self):
        """
        Disables the button so that it is no longer interactive.
        """
        if self.is_enabled:
            self.is_enabled = False

            # clear state
            self.is_focused = False
            self.selection_in_progress = False
            self.cursor_on = False
            self.cursor_has_moved_recently = False

            if self.drawable_shape is not None:
                self.drawable_shape.set_active_state('disabled')
                self.background_and_border = self.drawable_shape.get_surface('disabled')
            self.edit_position = 0
            self.select_range = [0, 0]
            self.redraw()

    def enable(self):
        """
        Re-enables the button so we can once again interact with it.
        """
        if not self.is_enabled:
            self.is_enabled = True
            self.text_entered = False
            if self.drawable_shape is not None:
                self.drawable_shape.set_active_state('normal')
                self.background_and_border = self.drawable_shape.get_surface('normal')
            self.redraw()

    def on_locale_changed(self):
        font = self.ui_theme.get_font(self.combined_element_ids)
        if font != self.font:
            self.font = font
            self.rebuild()
        else:
            if self.drawable_shape is not None:
                self.drawable_shape.set_text(translate(self.text))
Example #10
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.
    :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.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 = (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.formatted_text_block = None  # TextLine()
        self.text_wrap_rect = None
        self.background_surf = None

        self.drawable_shape = None
        self.shape = '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

        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.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)

                # 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.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,
                                                      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.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), 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.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_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.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_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.font
        rendered text.
        """
        parser = TextHTMLParser(self.ui_theme, self.combined_element_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_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.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 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 = 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.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 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.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 = self.ui_manager.calculate_scaled_mouse_position(
                event.pos)
            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.
        """
        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

        def tuple_extract(str_data: str) -> Tuple[int, int]:
            return int(str_data.split(',')[0]), int(str_data.split(',')[1])

        if self._check_misc_theme_data_changed(attribute_name='padding',
                                               default_value=(5, 5),
                                               casting_func=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_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

        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()