コード例 #1
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.
    """
    def __init__(self,
                 html_text: str,
                 relative_rect: pygame.Rect,
                 manager: UIManager,
                 wrap_to_height: bool = False,
                 layer_starting_height: int = 1,
                 container: ui_container.UIContainer = None,
                 parent_element: UIElement = None,
                 object_id: Union[str, None] = None):

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

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

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

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

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

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

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

        self.rebuild_from_changed_theme_data()

    def kill(self):
        """
        Overrides the standard sprite kill method to also kill any scroll bar 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.

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

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

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

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

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

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

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

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

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

        self.formatted_text_block.add_chunks_to_hover_group(
            self.link_hover_chunks)

    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.
        """
        if self.alive():
            if self.scroll_bar is not None:
                if self.scroll_bar.check_has_moved_recently():
                    height_adjustment = int(
                        self.scroll_bar.start_percentage *
                        self.formatted_text_block.final_dimensions[1])
                    drawable_area = pygame.Rect(
                        (0, height_adjustment),
                        (self.text_wrap_rect[2], self.text_wrap_rect[3]))
                    self.image = pygame.Surface(self.rect.size,
                                                flags=pygame.SRCALPHA,
                                                depth=32)
                    self.image.fill(pygame.Color(0, 0, 0, 0))
                    self.image.blit(self.background_surf, (0, 0))
                    self.image.blit(
                        self.formatted_text_block.block_sprite,
                        (self.padding[0] + self.border_width +
                         self.shadow_width + self.rounded_corner_offset,
                         self.padding[1] + self.border_width +
                         self.shadow_width + self.rounded_corner_offset),
                        drawable_area)

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

    def update_containing_rect_position(self):
        """
        Sets the final screen position of this element based on the position of it's container and it's relative
        position inside that container.
        """
        self.rect = pygame.Rect(
            (self.ui_container.rect.x + self.relative_rect.x,
             self.ui_container.rect.y + self.relative_rect.y),
            self.relative_rect.size)

        # for chunk in self.link_hover_chunks:
        #     chunk.rect = pygame.Rect((self.ui_container.rect.x + self.relative_rect.x + chunk.rect.x,
        #                               self.ui_container.rect.y + self.relative_rect.y + chunk.rect.y),
        #                              chunk.rect.size)

    def set_relative_position(self, position: Union[pygame.math.Vector2,
                                                    Tuple[int, int],
                                                    Tuple[float, float]]):
        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]]):
        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]]):
        self.rect.width = dimensions[0]
        self.rect.height = dimensions[1]
        self.relative_rect.width = dimensions[0]
        self.relative_rect.height = dimensions[1]

        self.rebuild()

    def parse_html_into_style_data(self):
        """
        Parses HTML styled string text into a format more useful for styling pygame.font rendered text.
        """
        parser = TextHTMLParser(self.ui_theme, self.element_ids,
                                self.object_ids)
        parser.push_style('body', {"bg_color": 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.scroll_bar is not None:
            height_adjustment = int(
                self.scroll_bar.start_percentage *
                self.formatted_text_block.final_dimensions[1])
        else:
            height_adjustment = 0

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

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

    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 bool: Returns True if we made use of this event.
        """
        processed_event = False
        should_redraw_from_chunks = False
        should_full_redraw = False
        if event.type == pygame.MOUSEBUTTONDOWN:
            if event.button == 1:
                scaled_mouse_pos = (
                    int(event.pos[0] *
                        self.ui_manager.mouse_pos_scale_factor[0]),
                    int(event.pos[1] *
                        self.ui_manager.mouse_pos_scale_factor[1]))
                if self.drawable_shape.collide_point(scaled_mouse_pos):
                    processed_event = True
                    if self.scroll_bar is not None:
                        text_block_full_height = self.formatted_text_block.final_dimensions[
                            1]
                        height_adjustment = self.scroll_bar.start_percentage * text_block_full_height
                    else:
                        height_adjustment = 0
                    base_x = int(self.rect[0] + self.padding[0] +
                                 self.border_width + self.shadow_width +
                                 self.rounded_corner_offset)
                    base_y = int(self.rect[1] + self.padding[1] +
                                 self.border_width + self.shadow_width +
                                 self.rounded_corner_offset -
                                 height_adjustment)
                    for chunk in self.link_hover_chunks:

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

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

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

                    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 processed_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 type(effect_name) is str:
            if effect_name == pygame_gui.TEXT_EFFECT_TYPING_APPEAR:
                effect = TypingAppearEffect(
                    self.formatted_text_block.characters)
                self.active_text_effect = effect
                self.full_redraw()
            elif effect_name == pygame_gui.TEXT_EFFECT_FADE_IN:
                effect = FadeInEffect(self.formatted_text_block.characters)
                self.active_text_effect = effect
                self.redraw_from_chunks()
            elif effect_name == pygame_gui.TEXT_EFFECT_FADE_OUT:
                effect = FadeOutEffect(self.formatted_text_block.characters)
                self.active_text_effect = effect
                self.redraw_from_chunks()
            else:
                warnings.warn('Unsupported effect name: ' + effect_name +
                              ' for text box')

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

        # misc parameters
        shape_type = 'rectangle'
        shape_type_string = self.ui_theme.get_misc_data(
            self.object_ids, self.element_ids, 'shape')
        if shape_type_string is not None:
            if 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

        corner_radius = 2
        shape_corner_radius_string = self.ui_theme.get_misc_data(
            self.object_ids, self.element_ids, 'shape_corner_radius')
        if shape_corner_radius_string is not None:
            try:
                corner_radius = int(shape_corner_radius_string)
            except ValueError:
                corner_radius = 2
        if corner_radius != self.shape_corner_radius:
            self.shape_corner_radius = corner_radius
            has_any_changed = True

        border_width = 0
        border_width_string = self.ui_theme.get_misc_data(
            self.object_ids, self.element_ids, 'border_width')
        if border_width_string is not None:
            try:
                border_width = int(border_width_string)
            except ValueError:
                border_width = 0

        if border_width != self.border_width:
            self.border_width = border_width
            has_any_changed = True

        shadow_width = 0
        shadow_width_string = self.ui_theme.get_misc_data(
            self.object_ids, self.element_ids, 'shadow_width')
        if shadow_width_string is not None:
            try:
                shadow_width = int(shadow_width_string)
            except ValueError:
                shadow_width = 0
        if shadow_width != self.shadow_width:
            self.shadow_width = shadow_width
            has_any_changed = True

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

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

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

        # link styles
        link_normal_underline = True
        link_normal_underline_string = self.ui_theme.get_misc_data(
            self.object_ids, self.element_ids, 'link_normal_underline')
        if link_normal_underline_string is not None:
            try:
                link_normal_underline = bool(int(link_normal_underline_string))
            except ValueError:
                link_normal_underline = True
        if link_normal_underline != self.link_normal_underline:
            self.link_normal_underline = link_normal_underline

        link_hover_underline = True
        link_hover_underline_string = self.ui_theme.get_misc_data(
            self.object_ids, self.element_ids, 'link_hover_underline')
        if link_hover_underline_string is not None:
            try:
                link_hover_underline = bool(int(link_hover_underline_string))
            except ValueError:
                link_hover_underline = True
        if link_hover_underline != self.link_hover_underline:
            self.link_hover_underline = link_hover_underline

        link_normal_colour = self.ui_theme.get_colour_or_gradient(
            self.object_ids, self.element_ids, 'link_text')
        if link_normal_colour != self.link_normal_colour:
            self.link_normal_colour = link_normal_colour

        link_hover_colour = self.ui_theme.get_colour_or_gradient(
            self.object_ids, self.element_ids, 'link_hover')
        if link_hover_colour != self.link_hover_colour:
            self.link_hover_colour = link_hover_colour

        link_selected_colour = self.ui_theme.get_colour_or_gradient(
            self.object_ids, self.element_ids, 'link_selected')
        if link_selected_colour != self.link_selected_colour:
            self.link_selected_colour = link_selected_colour

        link_style = {
            'link_text': self.link_normal_colour,
            'link_hover': self.link_hover_colour,
            'link_selected': self.link_selected_colour,
            'link_normal_underline': self.link_normal_underline,
            'link_hover_underline': self.link_hover_underline
        }

        if link_style != self.link_style:
            self.link_style = link_style
            has_any_changed = True

        if has_any_changed:
            self.rebuild()
コード例 #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()
コード例 #3
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))