class UIVerticalScrollBar(UIElement): """ A vertical scroll bar allows users to position a smaller visible area within a vertically larger area. :param relative_rect: The size and position of the scroll bar. :param visible_percentage: The vertical percentage of the larger area that is visible, between 0.0 and 1.0. :param manager: The UIManager that manages this element. :param container: The container that this element is within. If set to None will be the root window's container. :param parent_element: The element this element 'belongs to' in the theming hierarchy. :param object_id: A custom defined ID for fine tuning of theming. :param anchors: A dictionary describing what this element's relative_rect is relative to. :param visible: Whether the element is visible by default. Warning - container visibility may override this. """ def __init__(self, relative_rect: pygame.Rect, visible_percentage: float, manager: IUIManagerInterface, container: Union[IContainerLikeInterface, None] = None, parent_element: UIElement = None, object_id: Union[ObjectID, str, None] = None, anchors: Dict[str, str] = None, visible: int = 1): super().__init__(relative_rect, manager, container, layer_thickness=2, starting_height=1, anchors=anchors, visible=visible) self._create_valid_ids(container=container, parent_element=parent_element, object_id=object_id, element_id='vertical_scroll_bar') self.button_height = 20 self.arrow_button_height = self.button_height self.scroll_position = 0.0 self.top_limit = 0.0 self.starting_grab_y_difference = 0 self.visible_percentage = max(0.0, min(visible_percentage, 1.0)) self.start_percentage = 0.0 self.grabbed_slider = False self.has_moved_recently = False self.scroll_wheel_moved = False self.scroll_wheel_amount = 0 self.background_colour = None self.border_colour = None self.disabled_border_colour = None self.disabled_background_colour = None self.border_width = None self.shadow_width = None self.drawable_shape = None self.shape = 'rectangle' self.shape_corner_radius = None self.background_rect = None # type: Union[None, pygame.Rect] self.scrollable_height = None # type: Union[None, int, float] self.bottom_limit = None self.sliding_rect_position = None # type: Union[None, pygame.math.Vector2] self.top_button = None self.bottom_button = None self.sliding_button = None self.enable_arrow_buttons = True self.button_container = None self.rebuild_from_changed_theme_data() scroll_bar_height = max( 5, int(self.scrollable_height * self.visible_percentage)) self.sliding_button = UIButton(pygame.Rect( (int(self.sliding_rect_position[0]), int(self.sliding_rect_position[1])), (self.background_rect.width, scroll_bar_height)), '', self.ui_manager, container=self.button_container, starting_height=1, parent_element=self, object_id="#sliding_button", anchors={ 'left': 'left', 'right': 'right', 'top': 'top', 'bottom': 'top' }) self.join_focus_sets(self.sliding_button) self.sliding_button.set_hold_range((100, self.background_rect.height)) def rebuild(self): """ Rebuild anything that might need rebuilding. """ border_and_shadow = self.border_width + self.shadow_width self.background_rect = pygame.Rect( (border_and_shadow + self.relative_rect.x, border_and_shadow + self.relative_rect.y), (self.relative_rect.width - (2 * border_and_shadow), self.relative_rect.height - (2 * border_and_shadow))) theming_parameters = { 'normal_bg': self.background_colour, 'normal_border': self.border_colour, 'disabled_bg': self.disabled_background_colour, 'disabled_border': self.disabled_border_colour, 'border_width': self.border_width, 'shadow_width': self.shadow_width, 'shape_corner_radius': self.shape_corner_radius } if self.shape == 'rectangle': self.drawable_shape = RectDrawableShape(self.rect, theming_parameters, ['normal', 'disabled'], self.ui_manager) elif self.shape == 'rounded_rectangle': self.drawable_shape = RoundedRectangleShape( self.rect, theming_parameters, ['normal', 'disabled'], self.ui_manager) self.set_image(self.drawable_shape.get_fresh_surface()) if self.button_container is None: self.button_container = UIContainer( self.background_rect, manager=self.ui_manager, container=self.ui_container, anchors=self.anchors, object_id='#vert_scrollbar_buttons_container', visible=self.visible) self.join_focus_sets(self.button_container) else: self.button_container.set_dimensions(self.background_rect.size) self.button_container.set_relative_position( self.background_rect.topleft) if self.enable_arrow_buttons: self.arrow_button_height = self.button_height if self.top_button is None: self.top_button = UIButton(pygame.Rect( (0, 0), (self.background_rect.width, self.arrow_button_height)), '▲', self.ui_manager, container=self.button_container, starting_height=1, parent_element=self, object_id=ObjectID( "#top_button", "@arrow_button"), anchors={ 'left': 'left', 'right': 'right', 'top': 'top', 'bottom': 'top' }) self.join_focus_sets(self.top_button) if self.bottom_button is None: self.bottom_button = UIButton(pygame.Rect( (0, -self.arrow_button_height), (self.background_rect.width, self.arrow_button_height)), '▼', self.ui_manager, container=self.button_container, starting_height=1, parent_element=self, object_id=ObjectID( "#bottom_button", "@arrow_button"), anchors={ 'left': 'left', 'right': 'right', 'top': 'bottom', 'bottom': 'bottom' }) self.join_focus_sets(self.bottom_button) else: self.arrow_button_height = 0 if self.top_button is not None: self.top_button.kill() self.top_button = None if self.bottom_button is not None: self.bottom_button.kill() self.bottom_button = None self.scrollable_height = self.background_rect.height - ( 2 * self.arrow_button_height) self.bottom_limit = self.scrollable_height scroll_bar_height = max( 5, int(self.scrollable_height * self.visible_percentage)) self.scroll_position = min(max(self.scroll_position, self.top_limit), self.bottom_limit - scroll_bar_height) x_pos = 0 y_pos = (self.scroll_position + self.arrow_button_height) self.sliding_rect_position = pygame.math.Vector2(x_pos, y_pos) if self.sliding_button is not None: self.sliding_button.set_relative_position( self.sliding_rect_position) self.sliding_button.set_dimensions( (self.background_rect.width, scroll_bar_height)) self.sliding_button.set_hold_range( (100, self.background_rect.height)) def check_has_moved_recently(self) -> bool: """ Returns True if the scroll bar was moved in the last call to the update function. :return: True if we've recently moved the scroll bar, False otherwise. """ return self.has_moved_recently def kill(self): """ Overrides the kill() method of the UI element class to kill all the buttons in the scroll bar and clear any of the parts of the scroll bar that are currently recorded as the 'last focused vertical scroll bar element' on the ui manager. NOTE: the 'last focused' state on the UI manager is used so that the mouse wheel will move whichever scrollbar we last fiddled with even if we've been doing other stuff. This seems to be consistent with the most common mousewheel/scrollbar interactions used elsewhere. """ self.button_container.kill() super().kill() def process_event(self, event: pygame.event.Event) -> bool: """ Checks an event from pygame's event queue to see if the scroll bar needs to react to it. In this case it is just mousewheel events, mainly because the buttons that make up the scroll bar will handle the required mouse click events. :param event: The event to process. :return: Returns True if we've done something with the input event. """ consumed_event = False if (self.is_enabled and self._check_is_focus_set_hovered() and event.type == pygame.MOUSEWHEEL): self.scroll_wheel_moved = True self.scroll_wheel_amount = event.y consumed_event = True return consumed_event def _check_is_focus_set_hovered(self) -> bool: """ Check if this scroll bar's focus set is currently hovered in the UI. :return: True if it was. """ return any(element.hovered for element in self.get_focus_set()) def update(self, time_delta: float): """ Called once per update loop of our UI manager. Deals largely with moving the scroll bar and updating the resulting 'start_percentage' variable that is then used by other 'scrollable' UI elements to control the point they start drawing. Reacts to presses of the up and down arrow buttons, movement of the mouse wheel and dragging of the scroll bar itself. :param time_delta: A float, roughly representing the time in seconds between calls to this method. """ super().update(time_delta) self.has_moved_recently = False if self.alive(): moved_this_frame = False if self.scroll_wheel_moved and ( self.scroll_position > self.top_limit or self.scroll_position < self.bottom_limit): self.scroll_wheel_moved = False self.scroll_position -= self.scroll_wheel_amount * (750.0 * time_delta) self.scroll_position = min( max(self.scroll_position, self.top_limit), self.bottom_limit - self.sliding_button.relative_rect.height) x_pos = 0 y_pos = (self.scroll_position + self.arrow_button_height) self.sliding_button.set_relative_position((x_pos, y_pos)) moved_this_frame = True elif self.top_button is not None and self.top_button.held: self.scroll_position -= (250.0 * time_delta) self.scroll_position = max(self.scroll_position, self.top_limit) x_pos = 0 y_pos = (self.scroll_position + self.arrow_button_height) self.sliding_button.set_relative_position((x_pos, y_pos)) moved_this_frame = True elif self.bottom_button is not None and self.bottom_button.held: self.scroll_position += (250.0 * time_delta) self.scroll_position = min( self.scroll_position, self.bottom_limit - self.sliding_button.relative_rect.height) x_pos = 0 y_pos = (self.scroll_position + self.arrow_button_height) self.sliding_button.set_relative_position((x_pos, y_pos)) moved_this_frame = True mouse_x, mouse_y = self.ui_manager.get_mouse_position() if self.sliding_button.held and self.sliding_button.in_hold_range( (mouse_x, mouse_y)): if not self.grabbed_slider: self.grabbed_slider = True real_scroll_pos = self.sliding_button.rect.top self.starting_grab_y_difference = mouse_y - real_scroll_pos real_scroll_pos = self.sliding_button.rect.top current_grab_difference = mouse_y - real_scroll_pos adjustment_required = current_grab_difference - self.starting_grab_y_difference self.scroll_position = self.scroll_position + adjustment_required self.scroll_position = min( max(self.scroll_position, self.top_limit), self.bottom_limit - self.sliding_button.rect.height) x_pos = 0 y_pos = (self.scroll_position + self.arrow_button_height) self.sliding_button.set_relative_position((x_pos, y_pos)) moved_this_frame = True elif not self.sliding_button.held: self.grabbed_slider = False if moved_this_frame: self.start_percentage = self.scroll_position / self.scrollable_height if not self.has_moved_recently: self.has_moved_recently = True def redraw_scrollbar(self): """ Redraws the 'scrollbar' portion of the whole UI element. Called when we change the visible percentage. """ self.scrollable_height = self.background_rect.height - ( 2 * self.arrow_button_height) self.bottom_limit = self.scrollable_height scroll_bar_height = max( 5, int(self.scrollable_height * self.visible_percentage)) x_pos = 0 y_pos = (self.scroll_position + self.arrow_button_height) self.sliding_rect_position = pygame.math.Vector2(x_pos, y_pos) if self.sliding_button is None: self.sliding_button = UIButton(pygame.Rect( int(x_pos), int(y_pos), self.background_rect.width, scroll_bar_height), '', self.ui_manager, container=self.button_container, starting_height=1, parent_element=self, object_id="#sliding_button", anchors={ 'left': 'left', 'right': 'right', 'top': 'top', 'bottom': 'top' }) self.join_focus_sets(self.sliding_button) else: self.sliding_button.set_relative_position( self.sliding_rect_position) self.sliding_button.set_dimensions( (self.background_rect.width, scroll_bar_height)) self.sliding_button.set_hold_range((100, self.background_rect.height)) def set_visible_percentage(self, percentage: float): """ Sets the percentage of the total 'scrollable area' that is currently visible. This will affect the size of the scrollbar and should be called if the vertical size of the 'scrollable area' or the vertical size of the visible area change. :param percentage: A float between 0.0 and 1.0 representing the percentage that is visible. """ self.visible_percentage = max(0.0, min(1.0, percentage)) if 1.0 - self.start_percentage < self.visible_percentage: self.start_percentage = 1.0 - self.visible_percentage self.redraw_scrollbar() def reset_scroll_position(self): """ Reset the current scroll position back to the top. """ self.scroll_position = 0.0 self.start_percentage = 0.0 def rebuild_from_changed_theme_data(self): """ Called by the UIManager to check the theming data and rebuild whatever needs rebuilding for this element when the theme data has changed. """ super().rebuild_from_changed_theme_data() has_any_changed = False if self._check_misc_theme_data_changed( attribute_name='shape', default_value='rectangle', casting_func=str, allowed_values=['rectangle', 'rounded_rectangle']): has_any_changed = True if self._check_shape_theming_changed(defaults={ 'border_width': 1, 'shadow_width': 2, 'shape_corner_radius': 2 }): has_any_changed = True background_colour = self.ui_theme.get_colour_or_gradient( 'dark_bg', self.combined_element_ids) if background_colour != self.background_colour: self.background_colour = background_colour has_any_changed = True border_colour = self.ui_theme.get_colour_or_gradient( 'normal_border', self.combined_element_ids) if border_colour != self.border_colour: self.border_colour = border_colour has_any_changed = True disabled_background_colour = self.ui_theme.get_colour_or_gradient( 'disabled_dark_bg', self.combined_element_ids) if disabled_background_colour != self.disabled_background_colour: self.disabled_background_colour = disabled_background_colour has_any_changed = True disabled_border_colour = self.ui_theme.get_colour_or_gradient( 'disabled_border', self.combined_element_ids) if disabled_border_colour != self.disabled_border_colour: self.disabled_border_colour = disabled_border_colour has_any_changed = True def parse_to_bool(str_data: str): return bool(int(str_data)) if self._check_misc_theme_data_changed( attribute_name='enable_arrow_buttons', default_value=True, casting_func=parse_to_bool): has_any_changed = True if has_any_changed: self.rebuild() def set_position(self, position: Union[pygame.math.Vector2, Tuple[int, int], Tuple[float, float]]): """ Sets the absolute screen position of this scroll bar, updating all subordinate button elements at the same time. :param position: The absolute screen position to set. """ super().set_position(position) border_and_shadow = self.border_width + self.shadow_width self.background_rect.x = border_and_shadow + self.relative_rect.x self.background_rect.y = border_and_shadow + self.relative_rect.y self.button_container.set_relative_position( self.background_rect.topleft) def set_relative_position(self, position: Union[pygame.math.Vector2, Tuple[int, int], Tuple[float, float]]): """ Sets the relative screen position of this scroll bar, updating all subordinate button elements at the same time. :param position: The relative screen position to set. """ super().set_relative_position(position) border_and_shadow = self.border_width + self.shadow_width self.background_rect.x = border_and_shadow + self.relative_rect.x self.background_rect.y = border_and_shadow + self.relative_rect.y self.button_container.set_relative_position( self.background_rect.topleft) def set_dimensions(self, dimensions: Union[pygame.math.Vector2, Tuple[int, int], Tuple[float, float]]): """ Method to directly set the dimensions of an element. :param dimensions: The new dimensions to set. """ super().set_dimensions(dimensions) border_and_shadow = self.border_width + self.shadow_width self.background_rect.width = self.relative_rect.width - ( 2 * border_and_shadow) self.background_rect.height = self.relative_rect.height - ( 2 * border_and_shadow) self.button_container.set_dimensions(self.background_rect.size) # sort out scroll bar parameters self.scrollable_height = self.background_rect.height - ( 2 * self.arrow_button_height) self.bottom_limit = self.scrollable_height scroll_bar_height = max( 5, int(self.scrollable_height * self.visible_percentage)) base_scroll_bar_y = self.arrow_button_height max_scroll_bar_y = base_scroll_bar_y + (self.scrollable_height - scroll_bar_height) self.sliding_rect_position.y = max( base_scroll_bar_y, min((base_scroll_bar_y + int(self.start_percentage * self.scrollable_height)), max_scroll_bar_y)) self.scroll_position = self.sliding_rect_position.y - base_scroll_bar_y self.sliding_button.set_dimensions( (self.background_rect.width, scroll_bar_height)) self.sliding_button.set_relative_position(self.sliding_rect_position) def disable(self): """ Disables the scroll bar so it is no longer interactive. """ if self.is_enabled: self.is_enabled = False self.button_container.disable() self.drawable_shape.set_active_state('disabled') def enable(self): """ Enables the scroll bar so it is interactive once again. """ if not self.is_enabled: self.is_enabled = True self.button_container.enable() self.drawable_shape.set_active_state('normal') def show(self): """ In addition to the base UIElement.show() - show the self.button_container which will propagate and show all the buttons. """ super().show() self.button_container.show() def hide(self): """ In addition to the base UIElement.hide() - hide the self.button_container which will propagate and hide all the buttons. """ super().hide() self.button_container.hide()
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 = ['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[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 = "" # theme font self.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 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.disabled_background_colour = None self.disabled_border_colour = None self.disabled_text_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.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, 'disabled_bg': self.disabled_background_colour, 'disabled_border': self.disabled_border_colour, 'border_width': self.border_width, 'shadow_width': self.shadow_width, 'shape_corner_radius': self.shape_corner_radius } if self.shape == 'rectangle': self.drawable_shape = RectDrawableShape(self.rect, theming_parameters, ['normal', 'disabled'], self.ui_manager) elif self.shape == 'rounded_rectangle': self.drawable_shape = RoundedRectangleShape( self.rect, theming_parameters, ['normal', 'disabled'], self.ui_manager) self.background_and_border = self.drawable_shape.get_fresh_surface() if self.text_image is None: self.text_image = pygame.surface.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.padding[0] - self.start_text_offset, self.text_image_rect.y + self.padding[1]), (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 self.is_enabled: 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) else: if isinstance(self.disabled_background_colour, ColourGradient): self.text_image.fill(pygame.Color("#FFFFFFFF")) self.disabled_background_colour.apply_gradient_to_surface( self.text_image) else: self.text_image.fill(self.disabled_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.padding[0] * 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: basic_blit( self.text_image, self.text_surface, self.padding, pygame.Rect( (self.start_text_offset, 0), (text_clip_width, (self.rect.height - (self.padding[1] * 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. """ self.text_surface = render_white_text_alpha_black_bg(font=self.font, text=self.text) if self.is_enabled: if isinstance(self.text_colour, ColourGradient): self.text_colour.apply_gradient_to_surface(self.text_surface) else: apply_colour_to_surface(self.text_colour, self.text_surface) else: if isinstance(self.disabled_text_colour, ColourGradient): self.disabled_text_colour.apply_gradient_to_surface( self.text_surface) else: apply_colour_to_surface(self.disabled_text_colour, self.text_surface) 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.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) basic_blit(select_area_surface, alpha_text, (0, 0)) else: if isinstance(self.selected_text_colour, ColourGradient): select_area_surface = pygame.surface.Surface( (select_area_width, overall_size[1]), flags=pygame.SRCALPHA, depth=32) select_area_surface.fill(self.selected_bg_colour) alpha_text = render_white_text_alpha_black_bg( font=self.font, text=select_area_text) self.selected_text_colour.apply_gradient_to_surface(alpha_text) basic_blit(select_area_surface, alpha_text, (0, 0)) else: select_area_surface = self.font.render( select_area_text, True, self.selected_text_colour, self.selected_bg_colour).convert_alpha() 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.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: basic_blit(self.text_surface, pre_select_area_surface, (0, 0)) basic_blit(self.text_surface, select_area_surface, (pre_select_width, 0)) if post_select_area_surface is not None: basic_blit(self.text_surface, 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.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. """ text_surface = render_white_text_alpha_black_bg(font=self.font, text=text) if isinstance(col_or_grad, ColourGradient): col_or_grad.apply_gradient_to_surface(text_surface) else: apply_colour_to_surface(col_or_grad, text_surface) 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() basic_blit(new_image, self.text_image, self.text_image_rect) if self.cursor_on and self.is_enabled: 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.padding[0] - 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.Surface(self.cursor.size, flags=pygame.SRCALPHA, depth=32) cursor_surface.fill(pygame.Color('#FFFFFFFF')) self.text_colour.apply_gradient_to_surface(cursor_surface) basic_blit(new_image, 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.should_redraw = False 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.is_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. """ super().unfocus() pygame.key.set_repeat(0) 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. """ super().focus() 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 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.text != initial_text_state: event_data = { 'user_type': 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)) 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 = 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: 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 = self.ui_manager.calculate_scaled_mouse_position( event.pos) 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.padding[0]) 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. """ 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 def tuple_extract(str_data: str) -> Tuple[int, int]: return int(str_data.split(',')[0]), int(str_data.split(',')[1]) if self._check_misc_theme_data_changed(attribute_name='padding', default_value=(2, 2), casting_func=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 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.padding[1]) + (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.Surface(self.text_image_rect.size, flags=pygame.SRCALPHA, depth=32) self.redraw() 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 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.drawable_shape.set_active_state('normal') self.background_and_border = self.drawable_shape.get_surface( 'normal') self.redraw()
class UIHorizontalSlider(UIElement): """ A horizontal slider is intended to help users adjust values within a range, for example a volume control. :param relative_rect: A rectangle describing the position and dimensions of the element. :param start_value: The value to start the slider at. :param value_range: The full range of values. :param manager: The UIManager that manages this element. :param container: The container that this element is within. If set to None will be the root window's container. :param parent_element: The element this element 'belongs to' in the theming hierarchy. :param object_id: A custom defined ID for fine tuning of theming. :param anchors: A dictionary describing what this element's relative_rect is relative to. :param visible: Whether the element is visible by default. Warning - container visibility may override this. :param click_increment: the amount to increment by when clicking one of the arrow buttons. """ def __init__(self, relative_rect: pygame.Rect, start_value: Union[float, int], value_range: Tuple[Union[float, int], Union[float, int]], manager: IUIManagerInterface, container: Union[IContainerLikeInterface, None] = None, parent_element: UIElement = None, object_id: Union[ObjectID, str, None] = None, anchors: Dict[str, str] = None, visible: int = 1, click_increment: Union[float, int] = 1): super().__init__(relative_rect, manager, container, layer_thickness=2, starting_height=1, anchors=anchors, visible=visible) self._create_valid_ids(container=container, parent_element=parent_element, object_id=object_id, element_id='horizontal_slider') self.default_button_width = 20 self.arrow_button_width = self.default_button_width self.sliding_button_width = self.default_button_width self.current_percentage = 0.5 self.left_limit_position = 0.0 self.starting_grab_x_difference = 0 if (isinstance(start_value, int) and isinstance(value_range[0], int) and isinstance(value_range[1], int)): self.use_integers_for_value = True else: self.use_integers_for_value = False self.value_range = value_range value_range_length = self.value_range[1] - self.value_range[0] self.current_value = self.value_range[0] + (self.current_percentage * value_range_length) if self.use_integers_for_value: self.current_value = int(self.current_value) self.grabbed_slider = False self.has_moved_recently = False self.has_been_moved_by_user_recently = False self.background_colour = None self.border_colour = None self.disabled_border_colour = None self.disabled_background_colour = None self.border_width = None self.shadow_width = None self.drawable_shape = None self.shape = 'rectangle' self.shape_corner_radius = None self.background_rect = None # type: Optional[pygame.Rect] self.scrollable_width = None self.right_limit_position = None self.scroll_position = None self.left_button = None self.right_button = None self.sliding_button = None self.enable_arrow_buttons = True self.button_container = None self.button_held_repeat_time = 0.2 self.button_held_repeat_acc = 0.0 self.increment = click_increment self.rebuild_from_changed_theme_data() sliding_x_pos = int(self.background_rect.width / 2 - self.sliding_button_width / 2) self.sliding_button = UIButton(pygame.Rect( (sliding_x_pos, 0), (self.sliding_button_width, self.background_rect.height)), '', self.ui_manager, container=self.button_container, starting_height=1, parent_element=self, object_id=ObjectID( object_id='#sliding_button', class_id='None'), anchors={ 'left': 'left', 'right': 'left', 'top': 'top', 'bottom': 'bottom' }, visible=self.visible) self.sliding_button.set_hold_range((self.background_rect.width, 100)) self.set_current_value(start_value) def rebuild(self): """ Rebuild anything that might need rebuilding. """ border_and_shadow = self.border_width + self.shadow_width self.background_rect = pygame.Rect( (border_and_shadow + self.relative_rect.x, border_and_shadow + self.relative_rect.y), (self.relative_rect.width - (2 * border_and_shadow), self.relative_rect.height - (2 * border_and_shadow))) theming_parameters = { 'normal_bg': self.background_colour, 'normal_border': self.border_colour, 'disabled_bg': self.disabled_background_colour, 'disabled_border': self.disabled_border_colour, 'border_width': self.border_width, 'shadow_width': self.shadow_width, 'shape_corner_radius': self.shape_corner_radius } if self.shape == 'rectangle': self.drawable_shape = RectDrawableShape(self.rect, theming_parameters, ['normal', 'disabled'], self.ui_manager) elif self.shape == 'rounded_rectangle': self.drawable_shape = RoundedRectangleShape( self.rect, theming_parameters, ['normal', 'disabled'], self.ui_manager) self.set_image(self.drawable_shape.get_fresh_surface()) if self.button_container is None: self.button_container = UIContainer( self.background_rect, manager=self.ui_manager, container=self.ui_container, anchors=self.anchors, object_id='#horiz_scrollbar_buttons_container', visible=self.visible) else: self.button_container.set_dimensions(self.background_rect.size) self.button_container.set_relative_position( self.background_rect.topleft) # Things below here depend on theme data so need to be updated on a rebuild if self.enable_arrow_buttons: self.arrow_button_width = self.default_button_width if self.left_button is None: self.left_button = UIButton(pygame.Rect( (0, 0), (self.arrow_button_width, self.background_rect.height)), '◀', self.ui_manager, container=self.button_container, starting_height=1, parent_element=self, object_id=ObjectID( "#left_button", "@arrow_button"), anchors={ 'left': 'left', 'right': 'left', 'top': 'top', 'bottom': 'bottom' }, visible=self.visible) if self.right_button is None: self.right_button = UIButton(pygame.Rect( (-self.arrow_button_width, 0), (self.arrow_button_width, self.background_rect.height)), '▶', self.ui_manager, container=self.button_container, starting_height=1, parent_element=self, object_id=ObjectID( "#right_button", "@arrow_button"), anchors={ 'left': 'right', 'right': 'right', 'top': 'top', 'bottom': 'bottom' }, visible=self.visible) else: self.arrow_button_width = 0 if self.left_button is not None: self.left_button.kill() self.left_button = None if self.right_button is not None: self.right_button.kill() self.right_button = None self.scrollable_width = (self.background_rect.width - self.sliding_button_width - (2 * self.arrow_button_width)) self.right_limit_position = self.scrollable_width self.scroll_position = self.scrollable_width / 2 if self.sliding_button is not None: sliding_x_pos = int((self.background_rect.width / 2) - (self.sliding_button_width / 2)) self.sliding_button.set_relative_position((sliding_x_pos, 0)) self.sliding_button.set_dimensions( (self.sliding_button_width, self.background_rect.height)) self.sliding_button.set_hold_range( (self.background_rect.width, 100)) self.set_current_value(self.current_value, False) def kill(self): """ Overrides the normal sprite kill() method to also kill the button elements that help make up the slider. """ self.button_container.kill() super().kill() def update(self, time_delta: float): """ Takes care of actually moving the slider based on interactions reported by the buttons or based on movement of the mouse if we are gripping the slider itself. :param time_delta: the time in seconds between calls to update. """ super().update(time_delta) if not (self.alive() and self.is_enabled): return moved_this_frame = False moved_this_frame = self._update_arrow_buttons(moved_this_frame, time_delta) mouse_x, mouse_y = self.ui_manager.get_mouse_position() if self.sliding_button.held and self.sliding_button.in_hold_range( (mouse_x, mouse_y)): if not self.grabbed_slider: self.grabbed_slider = True real_scroll_pos = self.sliding_button.rect.left self.starting_grab_x_difference = mouse_x - real_scroll_pos real_scroll_pos = self.sliding_button.rect.left current_grab_difference = mouse_x - real_scroll_pos adjustment_required = current_grab_difference - self.starting_grab_x_difference self.scroll_position = self.scroll_position + adjustment_required self.scroll_position = min( max(self.scroll_position, self.left_limit_position), self.right_limit_position) x_pos = (self.scroll_position + self.arrow_button_width) y_pos = 0 self.sliding_button.set_relative_position((x_pos, y_pos)) moved_this_frame = True elif not self.sliding_button.held: self.grabbed_slider = False if moved_this_frame: self.current_percentage = self.scroll_position / self.scrollable_width self.current_value = self.value_range[0] + ( self.current_percentage * (self.value_range[1] - self.value_range[0])) if self.use_integers_for_value: self.current_value = int(self.current_value) if not self.has_moved_recently: self.has_moved_recently = True if not self.has_been_moved_by_user_recently: self.has_been_moved_by_user_recently = True # old event - to be removed in 0.8.0 event_data = { 'user_type': OldType(UI_HORIZONTAL_SLIDER_MOVED), 'value': self.current_value, 'ui_element': self, 'ui_object_id': self.most_specific_combined_id } pygame.event.post(pygame.event.Event(pygame.USEREVENT, event_data)) # new event event_data = { 'value': self.current_value, 'ui_element': self, 'ui_object_id': self.most_specific_combined_id } pygame.event.post( pygame.event.Event(UI_HORIZONTAL_SLIDER_MOVED, event_data)) def _update_arrow_buttons(self, moved_this_frame, time_delta): if self.left_button is not None and ( self.left_button.held and self.scroll_position > self.left_limit_position): if self.button_held_repeat_acc > self.button_held_repeat_time: self.scroll_position -= (250.0 * time_delta) self.scroll_position = max(self.scroll_position, self.left_limit_position) x_pos = (self.scroll_position + self.arrow_button_width) y_pos = 0 self.sliding_button.set_relative_position((x_pos, y_pos)) moved_this_frame = True else: self.button_held_repeat_acc += time_delta elif self.right_button is not None and ( self.right_button.held and self.scroll_position < self.right_limit_position): if self.button_held_repeat_acc > self.button_held_repeat_time: self.scroll_position += (250.0 * time_delta) self.scroll_position = min(self.scroll_position, self.right_limit_position) x_pos = (self.scroll_position + self.arrow_button_width) y_pos = 0 self.sliding_button.set_relative_position((x_pos, y_pos)) moved_this_frame = True else: self.button_held_repeat_acc += time_delta else: self.button_held_repeat_acc = 0.0 return moved_this_frame def process_event(self, event: pygame.event.Event) -> bool: processed_event = False if event.type == UI_BUTTON_PRESSED: if (event.ui_element in [self.left_button, self.right_button] and self.button_held_repeat_acc < self.button_held_repeat_time and (self.value_range[0] <= self.get_current_value() <= self.value_range[1])): old_value = self.get_current_value() new_value = (old_value - self.increment if event.ui_element == self.left_button else old_value + self.increment) self.set_current_value(new_value, False) processed_event = True event_data = { 'value': self.current_value, 'ui_element': self, 'ui_object_id': self.most_specific_combined_id } pygame.event.post( pygame.event.Event(UI_HORIZONTAL_SLIDER_MOVED, event_data)) return processed_event def get_current_value(self) -> Union[float, int]: """ Gets the current value the slider is set to. :return: The current value recorded by the slider. """ self.has_moved_recently = False self.has_been_moved_by_user_recently = False return self.current_value def set_current_value(self, value: Union[float, int], warn: bool = True): """ Sets the value of the slider, which will move the position of the slider to match. Will issue a warning if the value set is not in the value range. :param value: The value to set. :param warn: set to false to suppress the default warning, instead the value will be clamped. """ if self.use_integers_for_value: value = int(value) min_value = min(self.value_range[0], self.value_range[1]) max_value = max(self.value_range[0], self.value_range[1]) if value < min_value or value > max_value: if warn: warnings.warn('value not in range', UserWarning) return else: self.current_value = max(min(value, max_value), min_value) else: self.current_value = value value_range_size = (self.value_range[1] - self.value_range[0]) if value_range_size != 0: self.current_percentage = (float(self.current_value) - self.value_range[0]) / value_range_size self.scroll_position = self.scrollable_width * self.current_percentage x_pos = (self.scroll_position + self.arrow_button_width) y_pos = 0 self.sliding_button.set_relative_position((x_pos, y_pos)) self.has_moved_recently = True def rebuild_from_changed_theme_data(self): """ Called by the UIManager to check the theming data and rebuild whatever needs rebuilding for this element when the theme data has changed. """ super().rebuild_from_changed_theme_data() has_any_changed = False if self._check_misc_theme_data_changed( attribute_name='shape', default_value='rectangle', casting_func=str, allowed_values=['rectangle', 'rounded_rectangle']): has_any_changed = True if self._check_shape_theming_changed(defaults={ 'border_width': 1, 'shadow_width': 2, 'shape_corner_radius': 2 }): has_any_changed = True background_colour = self.ui_theme.get_colour_or_gradient( 'dark_bg', self.combined_element_ids) if background_colour != self.background_colour: self.background_colour = background_colour has_any_changed = True border_colour = self.ui_theme.get_colour_or_gradient( 'normal_border', self.combined_element_ids) if border_colour != self.border_colour: self.border_colour = border_colour has_any_changed = True disabled_background_colour = self.ui_theme.get_colour_or_gradient( 'disabled_dark_bg', self.combined_element_ids) if disabled_background_colour != self.disabled_background_colour: self.disabled_background_colour = disabled_background_colour has_any_changed = True disabled_border_colour = self.ui_theme.get_colour_or_gradient( 'disabled_border', self.combined_element_ids) if disabled_border_colour != self.disabled_border_colour: self.disabled_border_colour = disabled_border_colour has_any_changed = True def parse_to_bool(str_data: str): return bool(int(str_data)) if self._check_misc_theme_data_changed( attribute_name='enable_arrow_buttons', default_value=True, casting_func=parse_to_bool): has_any_changed = True if self._check_misc_theme_data_changed( attribute_name='sliding_button_width', default_value=self.default_button_width, casting_func=int): has_any_changed = True if has_any_changed: self.rebuild() def set_position(self, position: Union[pygame.math.Vector2, Tuple[int, int], Tuple[float, float]]): """ Sets the absolute screen position of this slider, updating all subordinate button elements at the same time. :param position: The absolute screen position to set. """ super().set_position(position) border_and_shadow = self.border_width + self.shadow_width self.background_rect.x = border_and_shadow + self.relative_rect.x self.background_rect.y = border_and_shadow + self.relative_rect.y self.button_container.set_relative_position( self.background_rect.topleft) def set_relative_position(self, position: Union[pygame.math.Vector2, Tuple[int, int], Tuple[float, float]]): """ Sets the relative screen position of this slider, updating all subordinate button elements at the same time. :param position: The relative screen position to set. """ super().set_relative_position(position) border_and_shadow = self.border_width + self.shadow_width self.background_rect.x = border_and_shadow + self.relative_rect.x self.background_rect.y = border_and_shadow + self.relative_rect.y self.button_container.set_relative_position( self.background_rect.topleft) def set_dimensions(self, dimensions: Union[pygame.math.Vector2, Tuple[int, int], Tuple[float, float]]): """ Method to directly set the dimensions of an element. :param dimensions: The new dimensions to set. """ super().set_dimensions(dimensions) border_and_shadow = self.border_width + self.shadow_width self.background_rect.width = self.relative_rect.width - ( 2 * border_and_shadow) self.background_rect.height = self.relative_rect.height - ( 2 * border_and_shadow) self.button_container.set_dimensions(self.background_rect.size) # sort out sliding button parameters self.scrollable_width = (self.background_rect.width - self.sliding_button_width - (2 * self.arrow_button_width)) self.right_limit_position = self.scrollable_width self.scroll_position = self.scrollable_width * self.current_percentage slider_x_pos = self.scroll_position + self.arrow_button_width slider_y_pos = 0 self.sliding_button.set_dimensions( (self.sliding_button_width, self.background_rect.height)) self.sliding_button.set_relative_position((slider_x_pos, slider_y_pos)) def disable(self): """ Disable the slider. It should not be interactive and will use the disabled theme colours. """ if self.is_enabled: self.is_enabled = False self.sliding_button.disable() if self.left_button: self.left_button.disable() if self.right_button: self.right_button.disable() self.drawable_shape.set_active_state('disabled') def enable(self): """ Enable the slider. It should become interactive and will use the normal theme colours. """ if not self.is_enabled: self.is_enabled = True self.sliding_button.enable() if self.left_button: self.left_button.enable() if self.right_button: self.right_button.enable() self.drawable_shape.set_active_state('normal') def show(self): """ In addition to the base UIElement.show() - show the sliding button and show the button_container which will propagate and show the left and right buttons. """ super().show() self.sliding_button.show() if self.button_container is not None: self.button_container.show() def hide(self): """ In addition to the base UIElement.hide() - hide the sliding button and hide the button_container which will propagate and hide the left and right buttons. """ super().hide() self.sliding_button.hide() if self.button_container is not None: self.button_container.hide()
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))