class UIPanel(UIElement, IContainerLikeInterface): """ A rectangular panel that holds a UI container and is designed to overlap other elements. It acts a little like a window that is not shuffled about in a stack - instead remaining at the same layer distance from the container it was initially placed in. It's primary purpose is for things like involved HUDs in games that want to always sit on top of UI elements that may be present 'inside' the game world (e.g. player health bars). By creating a UI Panel at a height above the highest layer used by the game world's UI elements we can ensure that all elements added to the panel are always above the fray. :param relative_rect: The positioning and sizing rectangle for the panel. See the layout guide for details. :param starting_layer_height: How many layers above its container to place this panel on. :param manager: The GUI manager that handles drawing and updating the UI and interactions between elements. :param margins: Controls the distance between the edge of the panel and where it's container should begin. :param container: The container this panel is inside of distinct from this panel's own container. :param parent_element: A hierarchical 'parent' used for signifying belonging and used in theming and events. :param object_id: An identifier that can be used to help distinguish this particular panel from others. :param anchors: Used to layout elements and dictate what the relative_rect is relative to. Defaults to the top left. :param visible: Whether the element is visible by default. Warning - container visibility may override this. """ def __init__(self, relative_rect: pygame.Rect, starting_layer_height: int, manager: IUIManagerInterface, *, element_id: str = 'panel', margins: Dict[str, int] = None, container: Union[IContainerLikeInterface, None] = None, parent_element: UIElement = None, object_id: Union[ObjectID, str, None] = None, anchors: Dict[str, str] = None, visible: int = 1): super().__init__(relative_rect, manager, container, starting_height=starting_layer_height, layer_thickness=1, anchors=anchors, visible=visible) self._create_valid_ids(container=container, parent_element=parent_element, object_id=object_id, element_id=element_id) self.background_colour = None self.border_colour = None self.background_image = None self.border_width = 1 self.shadow_width = 2 self.shape_corner_radius = 0 self.shape = 'rectangle' self.rebuild_from_changed_theme_data() if margins is None: self.container_margins = { 'left': self.shadow_width + self.border_width, 'right': self.shadow_width + self.border_width, 'top': self.shadow_width + self.border_width, 'bottom': self.shadow_width + self.border_width } else: self.container_margins = margins container_rect = pygame.Rect( self.relative_rect.left + self.container_margins['left'], self.relative_rect.top + self.container_margins['top'], self.relative_rect.width - (self.container_margins['left'] + self.container_margins['right']), self.relative_rect.height - (self.container_margins['top'] + self.container_margins['bottom'])) self.panel_container = UIContainer( container_rect, manager, starting_height=starting_layer_height, container=container, parent_element=self, object_id=ObjectID(object_id='#panel_container', class_id=None), anchors=anchors, visible=self.visible) def update(self, time_delta: float): """ A method called every update cycle of our application. Designed to be overridden by derived classes but also has a little functionality to make sure the panel's layer 'thickness' is accurate and to handle window resizing. :param time_delta: time passed in seconds between one call to this method and the next. """ super().update(time_delta) if self.get_container().get_thickness() != self.layer_thickness: self.layer_thickness = self.get_container().get_thickness() def process_event(self, event: pygame.event.Event) -> bool: """ Can be overridden, also handle resizing windows. Gives UI Windows access to pygame events. Currently just blocks mouse click down events from passing through the panel. :param event: The event to process. :return: Should return True if this element consumes this event. """ consumed_event = False if (self is not None and event.type == pygame.MOUSEBUTTONDOWN and event.button in [ pygame.BUTTON_LEFT, pygame.BUTTON_RIGHT, pygame.BUTTON_MIDDLE ]): scaled_mouse_pos = self.ui_manager.calculate_scaled_mouse_position( event.pos) if self.hover_point(scaled_mouse_pos[0], scaled_mouse_pos[1]): consumed_event = True return consumed_event def get_container(self) -> IUIContainerInterface: """ Returns the container that should contain all the UI elements in this panel. :return UIContainer: The panel's container. """ return self.panel_container def kill(self): """ Overrides the basic kill() method of a pygame sprite so that we also kill all the UI elements in this panel. """ self.get_container().kill() super().kill() def set_dimensions(self, dimensions: Union[pygame.math.Vector2, Tuple[int, int], Tuple[float, float]]): """ Set the size of this panel and then re-sizes and shifts the contents of the panel container to fit the new size. :param dimensions: The new dimensions to set. """ # Don't use a basic gate on this set dimensions method because the container may be a # different size to the window super().set_dimensions(dimensions) new_container_dimensions = ( self.relative_rect.width - (self.container_margins['left'] + self.container_margins['right']), self.relative_rect.height - (self.container_margins['top'] + self.container_margins['bottom'])) if new_container_dimensions != self.get_container().get_size(): container_rel_pos = (self.relative_rect.x + self.container_margins['left'], self.relative_rect.y + self.container_margins['top']) self.get_container().set_dimensions(new_container_dimensions) self.get_container().set_relative_position(container_rel_pos) def set_relative_position(self, position: Union[pygame.math.Vector2, Tuple[int, int], Tuple[float, float]]): """ Method to directly set the relative rect position of an element. :param position: The new position to set. """ super().set_relative_position(position) container_rel_pos = (self.relative_rect.x + self.container_margins['left'], self.relative_rect.y + self.container_margins['top']) self.get_container().set_relative_position(container_rel_pos) def set_position(self, position: Union[pygame.math.Vector2, Tuple[int, int], Tuple[float, float]]): """ Method to directly set the absolute screen rect position of an element. :param position: The new position to set. """ super().set_position(position) container_rel_pos = (self.relative_rect.x + self.container_margins['left'], self.relative_rect.y + self.container_margins['top']) self.get_container().set_relative_position(container_rel_pos) def rebuild_from_changed_theme_data(self): """ Checks if any theming parameters have changed, and if so triggers a full rebuild of the button's drawable shape. """ super().rebuild_from_changed_theme_data() has_any_changed = False background_colour = self.ui_theme.get_colour_or_gradient( 'dark_bg', self.combined_element_ids) if background_colour != self.background_colour: self.background_colour = background_colour has_any_changed = True border_colour = self.ui_theme.get_colour_or_gradient( 'normal_border', self.combined_element_ids) if border_colour != self.border_colour: self.border_colour = border_colour has_any_changed = True background_image = None try: background_image = self.ui_theme.get_image( 'background_image', self.combined_element_ids) except LookupError: background_image = None finally: if background_image != self.background_image: self.background_image = background_image has_any_changed = True # misc if self._check_misc_theme_data_changed( attribute_name='shape', default_value='rectangle', casting_func=str, allowed_values=['rectangle', 'rounded_rectangle']): has_any_changed = True if self._check_shape_theming_changed(defaults={ 'border_width': 1, 'shadow_width': 2, 'shape_corner_radius': 2 }): has_any_changed = True if has_any_changed: self.rebuild() def rebuild(self): """ A complete rebuild of the drawable shape used by this button. """ theming_parameters = { 'normal_bg': self.background_colour, 'normal_border': self.border_colour, 'normal_image': self.background_image, 'border_width': self.border_width, 'shadow_width': self.shadow_width, 'shape_corner_radius': self.shape_corner_radius } if self.shape == 'rectangle': self.drawable_shape = RectDrawableShape(self.rect, theming_parameters, ['normal'], self.ui_manager) elif self.shape == 'rounded_rectangle': self.drawable_shape = RoundedRectangleShape( self.rect, theming_parameters, ['normal'], self.ui_manager) self.on_fresh_drawable_shape_ready() def disable(self): """ Disables all elements in the panel so they are no longer interactive. """ if self.is_enabled: self.is_enabled = False self.panel_container.disable() def enable(self): """ Enables all elements in the panel so they are interactive again. """ if not self.is_enabled: self.is_enabled = True self.panel_container.enable() def show(self): """ In addition to the base UIElement.show() - call show() of owned container - panel_container. """ super().show() self.panel_container.show() def hide(self): """ In addition to the base UIElement.hide() - call hide() of owned container - panel_container. """ self.panel_container.hide() super().hide()
class UIScrollingContainer(UIElement, IContainerLikeInterface): """ A container like UI element that lets users scroll around a larger container of content with scroll bars. :param relative_rect: The size and relative position of the container. This will also be the starting size of the scrolling area. :param manager: The UI manager for this element. :param starting_height: The starting layer height of this container above it's container. Defaults to 1. :param container: The container this container is within. Defaults to None (which is the root container for the UI) :param parent_element: A parent element for this container. Defaults to None, or the container if you've set that. :param object_id: An object ID for this element. :param anchors: Layout anchors in a dictionary. :param visible: Whether the element is visible by default. Warning - container visibility may override this. """ def __init__(self, relative_rect: pygame.Rect, manager: IUIManagerInterface, *, starting_height: int = 1, container: Union[IContainerLikeInterface, None] = None, parent_element: Union[UIElement, None] = None, object_id: Union[ObjectID, str, None] = None, anchors: Union[Dict[str, str], None] = None, visible: int = 1): super().__init__(relative_rect, manager, container, starting_height=starting_height, layer_thickness=2, anchors=anchors, visible=visible) self._create_valid_ids(container=container, parent_element=parent_element, object_id=object_id, element_id='scrolling_container') # self.parent_element = parent_element self.scroll_bar_width = 0 self.scroll_bar_height = 0 self.need_to_sort_out_scrollbars = False self.vert_scroll_bar = None # type: Union[UIVerticalScrollBar, None] self.horiz_scroll_bar = None # type: Union[UIHorizontalScrollBar, None] self.set_image(self.ui_manager.get_universal_empty_surface()) # this contains the scroll bars and the 'view' container self._root_container = UIContainer(relative_rect=relative_rect, manager=manager, starting_height=starting_height, container=container, parent_element=parent_element, object_id=ObjectID(object_id='#root_container', class_id=None), anchors=anchors, visible=self.visible) # This container is the view on to the scrollable container it's size is determined by # the size of the root container and whether there are any scroll bars or not. view_rect = pygame.Rect(0, 0, relative_rect.width, relative_rect.height) self._view_container = UIContainer(relative_rect=view_rect, manager=manager, starting_height=0, container=self._root_container, parent_element=parent_element, object_id=ObjectID(object_id='#view_container', class_id=None), anchors={'left': 'left', 'right': 'right', 'top': 'top', 'bottom': 'bottom'}) # This container is what we actually put other stuff in. # It is aligned to the top left corner but that isn't that important for a container that # can be much larger than it's view scrollable_rect = pygame.Rect(0, 0, relative_rect.width, relative_rect.height) self.scrollable_container = UIContainer(relative_rect=scrollable_rect, manager=manager, starting_height=0, container=self._view_container, parent_element=parent_element, object_id=ObjectID( object_id='#scrollable_container', class_id=None), anchors={'left': 'left', 'right': 'left', 'top': 'top', 'bottom': 'top'}) self.scrolling_height = 0 self.scrolling_width = 0 self.scrolling_bottom = 0 self.scrolling_right = 0 self._calculate_scrolling_dimensions() def get_container(self) -> IUIContainerInterface: """ Gets the scrollable container area (the one that moves around with the scrollbars) from this container-like UI element. :return: the scrolling container. """ return self.scrollable_container def kill(self): """ Overrides the basic kill() method of a pygame sprite so that we also kill all the UI elements in this panel. """ self._root_container.kill() super().kill() def set_position(self, position: Union[pygame.math.Vector2, Tuple[int, int], Tuple[float, float]]): """ Method to directly set the absolute screen rect position of an element. :param position: The new position to set. """ super().set_position(position) self._root_container.set_dimensions(position) def set_relative_position(self, position: Union[pygame.math.Vector2, Tuple[int, int], Tuple[float, float]]): """ Method to directly set the relative rect position of an element. :param position: The new position to set. """ super().set_relative_position(position) self._root_container.set_relative_position(position) def set_dimensions(self, dimensions: Union[pygame.math.Vector2, Tuple[int, int], Tuple[float, float]]): """ Method to directly set the dimensions of an element. NOTE: Using this on elements inside containers with non-default anchoring arrangements may make a mess of them. :param dimensions: The new dimensions to set. """ super().set_dimensions(dimensions) self._root_container.set_dimensions(dimensions) self._calculate_scrolling_dimensions() self._sort_out_element_container_scroll_bars() def set_scrollable_area_dimensions(self, dimensions: Union[pygame.math.Vector2, Tuple[int, int], Tuple[float, float]]): """ Set the size of the scrollable area container. It starts the same size as the view container but often you want to expand it, or why have a scrollable container? :param dimensions: The new dimensions. """ self.scrollable_container.set_dimensions(dimensions) self._calculate_scrolling_dimensions() self._sort_out_element_container_scroll_bars() def update(self, time_delta: float): """ Updates the scrolling container's position based upon the scroll bars and updates the scrollbar's visible percentage as well if that has changed. :param time_delta: The time passed between frames, measured in seconds. """ super().update(time_delta) if (self.vert_scroll_bar is not None and self.vert_scroll_bar.check_has_moved_recently()): self._calculate_scrolling_dimensions() vis_percent = self._view_container.rect.height / self.scrolling_height if self.vert_scroll_bar.start_percentage <= 0.5: start_height = int(self.vert_scroll_bar.start_percentage * self.scrolling_height) else: button_percent_height = (self.vert_scroll_bar.sliding_button.rect.height / self.vert_scroll_bar.scrollable_height) button_bottom_percent = (self.vert_scroll_bar.start_percentage + button_percent_height) start_height = (int(button_bottom_percent * self.scrolling_height) - self._view_container.rect.height) if vis_percent < 1.0: self.vert_scroll_bar.set_visible_percentage(vis_percent) else: self._remove_vert_scrollbar() if self.scrolling_bottom < self._view_container.rect.bottom: start_height = min(start_height, self._view_container.rect.height) new_pos = (self.scrollable_container.relative_rect.x, -start_height) self.scrollable_container.set_relative_position(new_pos) if (self.horiz_scroll_bar is not None and self.horiz_scroll_bar.check_has_moved_recently()): self._calculate_scrolling_dimensions() vis_percent = self._view_container.rect.width / self.scrolling_width if self.horiz_scroll_bar.start_percentage <= 0.5: start_width = int(self.horiz_scroll_bar.start_percentage * self.scrolling_width) else: button_percent_width = (self.horiz_scroll_bar.sliding_button.rect.width / self.horiz_scroll_bar.scrollable_width) button_right_percent = (self.horiz_scroll_bar.start_percentage + button_percent_width) start_width = (int(button_right_percent * self.scrolling_width) - self._view_container.rect.width) if vis_percent < 1.0: self.horiz_scroll_bar.set_visible_percentage(vis_percent) else: self._remove_horiz_scrollbar() if self.scrolling_right < self._view_container.rect.right: start_width = min(start_width, self._view_container.rect.width) new_pos = (-start_width, self.scrollable_container.relative_rect.y) self.scrollable_container.set_relative_position(new_pos) def _calculate_scrolling_dimensions(self): """ Calculate all the variables we need to scroll the container correctly. This is a bit of a fiddly process since we can resize our viewing area, the scrollable area and we generally don't want to yank the area you are looking at too much either. Plus, the scrollbars only have somewhat limited accuracy so need clamping... """ scrolling_top = min(self.scrollable_container.rect.top, self._view_container.rect.top) scrolling_left = min(self.scrollable_container.rect.left, self._view_container.rect.left) # used for clamping self.scrolling_bottom = max(self.scrollable_container.rect.bottom, self._view_container.rect.bottom) self.scrolling_right = max(self.scrollable_container.rect.right, self._view_container.rect.right) self.scrolling_height = self.scrolling_bottom - scrolling_top self.scrolling_width = self.scrolling_right - scrolling_left def _sort_out_element_container_scroll_bars(self): """ This creates, re-sizes or removes the scrollbars after resizing, but not after the scroll bar has been moved. Instead it tries to keep the scrollbars in the same approximate position they were in before resizing """ self._check_scroll_bars_and_adjust() need_horiz_scroll_bar, need_vert_scroll_bar = self._check_scroll_bars_and_adjust() if need_vert_scroll_bar: vis_percent = self._view_container.rect.height / self.scrolling_height if self.vert_scroll_bar is None: self.scroll_bar_width = 20 scroll_bar_rect = pygame.Rect(-self.scroll_bar_width, 0, self.scroll_bar_width, self._view_container.rect.height) self.vert_scroll_bar = UIVerticalScrollBar(relative_rect=scroll_bar_rect, visible_percentage=vis_percent, manager=self.ui_manager, container=self._root_container, parent_element=self, anchors={'left': 'right', 'right': 'right', 'top': 'top', 'bottom': 'bottom'}) else: start_percent = ((self._view_container.rect.top - self.scrollable_container.rect.top) / self.scrolling_height) self.vert_scroll_bar.start_percentage = start_percent self.vert_scroll_bar.set_visible_percentage(vis_percent) self.vert_scroll_bar.set_dimensions((self.scroll_bar_width, self._view_container.rect.height)) else: self._remove_vert_scrollbar() if need_horiz_scroll_bar: vis_percent = self._view_container.rect.width / self.scrolling_width if self.horiz_scroll_bar is None: self.scroll_bar_height = 20 scroll_bar_rect = pygame.Rect(0, -self.scroll_bar_height, self._view_container.rect.width, self.scroll_bar_height) self.horiz_scroll_bar = UIHorizontalScrollBar(relative_rect=scroll_bar_rect, visible_percentage=vis_percent, manager=self.ui_manager, container=self._root_container, parent_element=self, anchors={'left': 'left', 'right': 'right', 'top': 'bottom', 'bottom': 'bottom'}) else: start_percent = ((self._view_container.rect.left - self.scrollable_container.rect.left) / self.scrolling_width) self.horiz_scroll_bar.start_percentage = start_percent self.horiz_scroll_bar.set_visible_percentage(vis_percent) self.horiz_scroll_bar.set_dimensions((self._view_container.rect.width, self.scroll_bar_height)) else: self._remove_horiz_scrollbar() def _check_scroll_bars_and_adjust(self): """ Check if we need a horizontal or vertical scrollbar and adjust the containers if we do. Adjusting the containers for a scrollbar, may mean we now need a scrollbar in the other dimension so we need to call this twice. """ self.scroll_bar_width = 0 self.scroll_bar_height = 0 need_horiz_scroll_bar = False need_vert_scroll_bar = False if (self.scrolling_height > self._view_container.rect.height or self.scrollable_container.relative_rect.top != 0): need_vert_scroll_bar = True self.scroll_bar_width = 20 if (self.scrolling_width > self._view_container.rect.width or self.scrollable_container.relative_rect.left != 0): need_horiz_scroll_bar = True self.scroll_bar_height = 20 if need_vert_scroll_bar or need_horiz_scroll_bar: new_width = (self._root_container.rect.width - self.scroll_bar_width) new_height = (self._root_container.rect.height - self.scroll_bar_height) new_dimensions = (new_width, new_height) self._view_container.set_dimensions(new_dimensions) self._calculate_scrolling_dimensions() return need_horiz_scroll_bar, need_vert_scroll_bar def _remove_vert_scrollbar(self): """ Get rid of the vertical scroll bar and resize the containers appropriately. """ if self.vert_scroll_bar is not None: self.vert_scroll_bar.kill() self.vert_scroll_bar = None self.scroll_bar_width = 0 new_width = (self._root_container.rect.width - self.scroll_bar_width) old_height = self._view_container.rect.height new_dimensions = (new_width, old_height) self._view_container.set_dimensions(new_dimensions) self._calculate_scrolling_dimensions() if self.horiz_scroll_bar is not None: self.horiz_scroll_bar.set_dimensions((self._view_container.rect.width, self.scroll_bar_height)) def _remove_horiz_scrollbar(self): """ Get rid of the horiz scroll bar and resize the containers appropriately. """ if self.horiz_scroll_bar is not None: self.horiz_scroll_bar.kill() self.horiz_scroll_bar = None self.scroll_bar_height = 0 new_height = (self._root_container.rect.height - self.scroll_bar_height) old_width = self._view_container.rect.width new_dimensions = (old_width, new_height) self._view_container.set_dimensions(new_dimensions) self._calculate_scrolling_dimensions() if self.vert_scroll_bar is not None: self.vert_scroll_bar.set_dimensions((self.scroll_bar_width, self._view_container.rect.height)) def disable(self): """ Disables all elements in the container so they are no longer interactive. """ if self.is_enabled: self.is_enabled = False self._root_container.disable() def enable(self): """ Enables all elements in the container so they are interactive again. """ if not self.is_enabled: self.is_enabled = True self._root_container.enable() def show(self): """ In addition to the base UIElement.show() - call show() of owned container - _root_container. All other subelements (view_container, scrollbars) are children of _root_container, so it's visibility will propagate to them - there is no need to call their show() methods separately. """ super().show() self._root_container.show() def hide(self): """ In addition to the base UIElement.hide() - call hide() of owned container - _root_container. All other subelements (view_container, scrollbars) are children of _root_container, so it's visibility will propagate to them - there is no need to call their hide() methods separately. """ self._root_container.hide() super().hide()
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 UISelectionList(UIElement): """ A rectangular element that holds any number of selectable text items displayed as a list. :param relative_rect: The positioning and sizing rectangle for the panel. See the layout guide for details. :param item_list: A list of items as strings (item name only), or tuples of two strings (name, theme_object_id). :param manager: The GUI manager that handles drawing and updating the UI and interactions between elements. :param allow_multi_select: True if we are allowed to pick multiple things from the selection list. :param allow_double_clicks: True if we can double click on items in the selection list. :param container: The container this element is inside of (by default the root container) distinct from this panel's container. :param starting_height: The starting height up from it's container where this list is placed into a layer. :param parent_element: A hierarchical 'parent' used for signifying belonging and used in theming and events. :param object_id: An identifier that can be used to help distinguish this particular element from others with the same hierarchy. :param anchors: Used to layout elements and dictate what the relative_rect is relative to. Defaults to the top left. :param visible: Whether the element is visible by default. Warning - container visibility may override this. """ def __init__(self, relative_rect: pygame.Rect, item_list: Union[List[str], List[Tuple[str, str]]], manager: IUIManagerInterface, *, allow_multi_select: bool = False, allow_double_clicks: bool = True, container: Union[IContainerLikeInterface, None] = None, starting_height: int = 1, parent_element: UIElement = None, object_id: Union[ObjectID, str, None] = None, anchors: Dict[str, str] = None, visible: int = 1): super().__init__(relative_rect, manager, container, starting_height=starting_height, layer_thickness=1, anchors=anchors, visible=visible) self._create_valid_ids(container=container, parent_element=parent_element, object_id=object_id, element_id='selection_list') self._parent_element = parent_element self.list_and_scroll_bar_container = None self.item_list_container = None self._raw_item_list = item_list self.item_list = [] self.allow_multi_select = allow_multi_select self.allow_double_clicks = allow_double_clicks self.background_colour = None self.border_colour = None self.background_image = None self.border_width = 1 self.shadow_width = 2 self.shape_corner_radius = 0 self.shape = 'rectangle' self.scroll_bar = None # type: Union[UIVerticalScrollBar, None] self.lowest_list_pos = 0 self.total_height_of_list = 0 self.list_item_height = 20 self.scroll_bar_width = 20 self.current_scroll_bar_width = 0 self.rebuild_from_changed_theme_data() def get_single_selection(self) -> Union[str, None]: """ Get the selected item in a list, if any. Only works if this is a single-selection list. :return: A single item name as a string or None. """ if not self.allow_multi_select: selected_list = [ item['text'] for item in self.item_list if item['selected'] ] if len(selected_list) == 1: return selected_list[0] elif len(selected_list) == 0: return None else: raise RuntimeError( 'More than one item selected in single-selection,' ' selection list') else: raise RuntimeError('Requesting single selection,' ' from multi-selection list') def get_multi_selection(self) -> List[str]: """ Get all the selected items in our selection list. Only works if this is a multi-selection list. :return: A list of the selected items in our selection list. May be empty if nothing selected. """ if self.allow_multi_select: return [ item['text'] for item in self.item_list if item['selected'] ] else: raise RuntimeError( 'Requesting multi selection, from single-selection list') def update(self, time_delta: float): """ A method called every update cycle of our application. Designed to be overridden by derived classes but also has a little functionality to make sure the panel's layer 'thickness' is accurate and to handle window resizing. :param time_delta: time passed in seconds between one call to this method and the next. """ super().update(time_delta) if self.scroll_bar is not None and self.scroll_bar.check_has_moved_recently( ): list_height_adjustment = min( self.scroll_bar.start_percentage * self.total_height_of_list, self.lowest_list_pos) for index, item in enumerate(self.item_list): new_height = int((index * self.list_item_height) - list_height_adjustment) if (-self.list_item_height <= new_height <= self.item_list_container.relative_rect.height): if item['button_element'] is not None: item['button_element'].set_relative_position( (0, new_height)) else: button_rect = pygame.Rect( 0, new_height, self.item_list_container.relative_rect.width, self.list_item_height) button = UIButton( relative_rect=button_rect, text=item['text'], manager=self.ui_manager, parent_element=self, container=self.item_list_container, object_id=ObjectID( object_id=item['object_id'], class_id='@selection_list_item'), allow_double_clicks=self.allow_double_clicks, anchors={ 'left': 'left', 'right': 'right', 'top': 'top', 'bottom': 'top' }) self.join_focus_sets(button) item['button_element'] = button if item['selected']: item['button_element'].select() else: if item['button_element'] is not None: item['button_element'].kill() item['button_element'] = None def set_item_list(self, new_item_list: Union[List[str], List[Tuple[str, str]]]): """ Set a new string list (or tuple of strings & ids list) as the item list for this selection list. This will change what is displayed in the list. Tuples should be arranged like so: (list_text, object_ID) - list_text: displayed in the UI - object_ID: used for theming and events :param new_item_list: The new list to switch to. Can be a list of strings or tuples. """ self._raw_item_list = new_item_list self.item_list = [] # type: List[Dict] for new_item in new_item_list: if isinstance(new_item, str): new_item_list_item = { 'text': new_item, 'button_element': None, 'selected': False, 'object_id': '#item_list_item' } elif isinstance(new_item, tuple): new_item_list_item = { 'text': new_item[0], 'button_element': None, 'selected': False, 'object_id': new_item[1] } else: raise ValueError('Invalid item list') self.item_list.append(new_item_list_item) self.total_height_of_list = self.list_item_height * len(self.item_list) self.lowest_list_pos = ( self.total_height_of_list - self.list_and_scroll_bar_container.relative_rect.height) inner_visible_area_height = self.list_and_scroll_bar_container.relative_rect.height if self.total_height_of_list > inner_visible_area_height: # we need a scroll bar self.current_scroll_bar_width = self.scroll_bar_width percentage_visible = inner_visible_area_height / max( self.total_height_of_list, 1) if self.scroll_bar is not None: self.scroll_bar.reset_scroll_position() self.scroll_bar.set_visible_percentage(percentage_visible) self.scroll_bar.start_percentage = 0 else: self.scroll_bar = UIVerticalScrollBar( pygame.Rect(-self.scroll_bar_width, 0, self.scroll_bar_width, inner_visible_area_height), visible_percentage=percentage_visible, manager=self.ui_manager, parent_element=self, container=self.list_and_scroll_bar_container, anchors={ 'left': 'right', 'right': 'right', 'top': 'top', 'bottom': 'bottom' }) self.join_focus_sets(self.scroll_bar) else: if self.scroll_bar is not None: self.scroll_bar.kill() self.scroll_bar = None self.current_scroll_bar_width = 0 # create button list container if self.item_list_container is not None: self.item_list_container.clear() if (self.item_list_container.relative_rect.width != (self.list_and_scroll_bar_container.relative_rect.width - self.current_scroll_bar_width)): container_dimensions = ( self.list_and_scroll_bar_container.relative_rect.width - self.current_scroll_bar_width, self.list_and_scroll_bar_container.relative_rect.height) self.item_list_container.set_dimensions(container_dimensions) else: self.item_list_container = UIContainer( pygame.Rect( 0, 0, self.list_and_scroll_bar_container.relative_rect.width - self.current_scroll_bar_width, self.list_and_scroll_bar_container.relative_rect.height), manager=self.ui_manager, starting_height=0, parent_element=self, container=self.list_and_scroll_bar_container, object_id='#item_list_container', anchors={ 'left': 'left', 'right': 'right', 'top': 'top', 'bottom': 'bottom' }) self.join_focus_sets(self.item_list_container) item_y_height = 0 for item in self.item_list: if item_y_height <= self.item_list_container.relative_rect.height: button_rect = pygame.Rect( 0, item_y_height, self.item_list_container.relative_rect.width, self.list_item_height) item['button_element'] = UIButton( relative_rect=button_rect, text=item['text'], manager=self.ui_manager, parent_element=self, container=self.item_list_container, object_id=ObjectID(object_id=item['object_id'], class_id='@selection_list_item'), allow_double_clicks=self.allow_double_clicks, anchors={ 'left': 'left', 'right': 'right', 'top': 'top', 'bottom': 'top' }) self.join_focus_sets(item['button_element']) item_y_height += self.list_item_height else: break def process_event(self, event: pygame.event.Event) -> bool: """ Can be overridden, also handle resizing windows. Gives UI Windows access to pygame events. Currently just blocks mouse click down events from passing through the panel. :param event: The event to process. :return: Should return True if this element makes use of this event. """ if self.is_enabled and ( event.type == pygame.USEREVENT and event.user_type in [UI_BUTTON_PRESSED, UI_BUTTON_DOUBLE_CLICKED] and event.ui_element in self.item_list_container.elements): for item in self.item_list: if item['button_element'] == event.ui_element: if event.user_type == UI_BUTTON_DOUBLE_CLICKED: event_data = { 'user_type': UI_SELECTION_LIST_DOUBLE_CLICKED_SELECTION, 'text': event.ui_element.text, 'ui_element': self, 'ui_object_id': self.most_specific_combined_id } else: if item['selected']: item['selected'] = False event.ui_element.unselect() event_data = { 'user_type': UI_SELECTION_LIST_DROPPED_SELECTION, 'text': event.ui_element.text, 'ui_element': self, 'ui_object_id': self.most_specific_combined_id } else: item['selected'] = True event.ui_element.select() event_data = { 'user_type': UI_SELECTION_LIST_NEW_SELECTION, 'text': event.ui_element.text, 'ui_element': self, 'ui_object_id': self.most_specific_combined_id } selection_list_event = pygame.event.Event( pygame.USEREVENT, event_data) pygame.event.post(selection_list_event) elif not self.allow_multi_select: if item['selected']: item['selected'] = False if item['button_element'] is not None: item['button_element'].unselect() event_data = { 'user_type': UI_SELECTION_LIST_DROPPED_SELECTION, 'text': item['text'], 'ui_element': self, 'ui_object_id': self.most_specific_combined_id } drop_down_changed_event = pygame.event.Event( pygame.USEREVENT, event_data) pygame.event.post(drop_down_changed_event) return False # Don't consume any events def set_dimensions(self, dimensions: Union[pygame.math.Vector2, Tuple[int, int], Tuple[float, float]]): """ Set the size of this panel and then resizes and shifts the contents of the panel container to fit the new size. :param dimensions: The new dimensions to set. """ # Don't use a basic gate on this set dimensions method because the container may be a # different size to the window super().set_dimensions(dimensions) border_and_shadow = self.border_width + self.shadow_width container_width = self.relative_rect.width - (2 * border_and_shadow) container_height = self.relative_rect.height - (2 * border_and_shadow) self.list_and_scroll_bar_container.set_dimensions( (container_width, container_height)) def set_relative_position(self, position: Union[pygame.math.Vector2, Tuple[int, int], Tuple[float, float]]): """ Method to directly set the relative rect position of an element. :param position: The new position to set. """ super().set_relative_position(position) border_and_shadow = self.border_width + self.shadow_width container_left = self.relative_rect.left + border_and_shadow container_top = self.relative_rect.top + border_and_shadow self.list_and_scroll_bar_container.set_relative_position( (container_left, container_top)) def set_position(self, position: Union[pygame.math.Vector2, Tuple[int, int], Tuple[float, float]]): """ Sets the absolute screen position of this slider, updating all subordinate button elements at the same time. :param position: The absolute screen position to set. """ super().set_position(position) border_and_shadow = self.border_width + self.shadow_width container_left = self.relative_rect.left + border_and_shadow container_top = self.relative_rect.top + border_and_shadow self.list_and_scroll_bar_container.set_relative_position( (container_left, container_top)) def kill(self): """ Overrides the basic kill() method of a pygame sprite so that we also kill all the UI elements in this panel. """ self.list_and_scroll_bar_container.kill() super().kill() def rebuild_from_changed_theme_data(self): """ Checks if any theming parameters have changed, and if so triggers a full rebuild of the button's drawable shape """ super().rebuild_from_changed_theme_data() has_any_changed = False background_colour = self.ui_theme.get_colour_or_gradient( 'dark_bg', self.combined_element_ids) if background_colour != self.background_colour: self.background_colour = background_colour has_any_changed = True border_colour = self.ui_theme.get_colour_or_gradient( 'normal_border', self.combined_element_ids) if border_colour != self.border_colour: self.border_colour = border_colour has_any_changed = True # misc if self._check_misc_theme_data_changed( attribute_name='shape', default_value='rectangle', casting_func=str, allowed_values=['rectangle', 'rounded_rectangle']): has_any_changed = True if self._check_shape_theming_changed(defaults={ 'border_width': 1, 'shadow_width': 2, 'shape_corner_radius': 2 }): has_any_changed = True if self._check_misc_theme_data_changed( attribute_name='list_item_height', default_value=20, casting_func=int): has_any_changed = True if has_any_changed: self.rebuild() def rebuild(self): """ A complete rebuild of the drawable shape used by this element. """ theming_parameters = { 'normal_bg': self.background_colour, 'normal_border': self.border_colour, 'normal_image': self.background_image, 'border_width': self.border_width, 'shadow_width': self.shadow_width, 'shape_corner_radius': self.shape_corner_radius } if self.shape == 'rectangle': self.drawable_shape = RectDrawableShape(self.rect, theming_parameters, ['normal'], self.ui_manager) elif self.shape == 'rounded_rectangle': self.drawable_shape = RoundedRectangleShape( self.rect, theming_parameters, ['normal'], self.ui_manager) self.on_fresh_drawable_shape_ready() if self.list_and_scroll_bar_container is None: self.list_and_scroll_bar_container = UIContainer( pygame.Rect( self.relative_rect.left + self.shadow_width + self.border_width, self.relative_rect.top + self.shadow_width + self.border_width, self.relative_rect.width - (2 * self.shadow_width) - (2 * self.border_width), self.relative_rect.height - (2 * self.shadow_width) - (2 * self.border_width)), manager=self.ui_manager, starting_height=self.starting_height, container=self.ui_container, parent_element=self._parent_element, object_id='#selection_list_container', anchors=self.anchors, visible=self.visible) self.join_focus_sets(self.list_and_scroll_bar_container) else: self.list_and_scroll_bar_container.set_dimensions( (self.relative_rect.width - (2 * self.shadow_width) - (2 * self.border_width), self.relative_rect.height - (2 * self.shadow_width) - (2 * self.border_width))) self.list_and_scroll_bar_container.set_relative_position( (self.relative_rect.left + self.shadow_width + self.border_width, self.relative_rect.top + self.shadow_width + self.border_width)) self.set_item_list(self._raw_item_list) def disable(self): """ Disables all elements in the selection list so they are no longer interactive. """ if self.is_enabled: self.is_enabled = False self.list_and_scroll_bar_container.disable() # clear selections for item in self.item_list: item['selected'] = False def enable(self): """ Enables all elements in the selection list so they are interactive again. """ if not self.is_enabled: self.is_enabled = True self.list_and_scroll_bar_container.enable() def show(self): """ In addition to the base UIElement.show() - call show() of owned container - list_and_scroll_bar_container. All other subelements (item_list_container, scrollbar) are children of list_and_scroll_bar_container, so it's visibility will propagate to them - there is no need to call their show() methods separately. """ super().show() self.list_and_scroll_bar_container.show() def hide(self): """ In addition to the base UIElement.hide() - call hide() of owned container - list_and_scroll_bar_container. All other subelements (item_list_container, scrollbar) are children of list_and_scroll_bar_container, so it's visibility will propagate to them - there is no need to call their hide() methods separately. """ super().hide() self.list_and_scroll_bar_container.hide()
class UIWindow(UIElement, IContainerLikeInterface, IWindowInterface): """ A base class for window GUI elements, any windows should inherit from this class. :param rect: A rectangle, representing size and position of the window (including title bar, shadow and borders). :param manager: The UIManager that manages this UIWindow. :param window_display_title: A string that will appear in the windows title bar if it has one. :param element_id: An element ID for this window, if one is not supplied it defaults to 'window'. :param object_id: An optional object ID for this window, useful for distinguishing different windows. :param resizable: Whether this window is resizable or not, defaults to False. :param visible: Whether the element is visible by default. Warning - container visibility may override this. """ def __init__(self, rect: pygame.Rect, manager: IUIManagerInterface, window_display_title: str = "", element_id: Union[str, None] = None, object_id: Union[ObjectID, str, None] = None, resizable: bool = False, visible: int = 1): self.window_display_title = window_display_title self._window_root_container = None # type: Union[UIContainer, None] self.resizable = resizable self.minimum_dimensions = (100, 100) self.edge_hovering = [False, False, False, False] super().__init__(rect, manager, container=None, starting_height=1, layer_thickness=1, visible=visible) if element_id is None: element_id = 'window' self._create_valid_ids(container=None, parent_element=None, object_id=object_id, element_id=element_id) self.set_image(self.ui_manager.get_universal_empty_surface()) self.bring_to_front_on_focused = True self.is_blocking = False # blocks all clicking events from interacting beyond this window self.resizing_mode_active = False self.start_resize_point = (0, 0) self.start_resize_rect = None # type: Union[pygame.Rect, None] self.grabbed_window = False self.starting_grab_difference = (0, 0) self.background_colour = None self.border_colour = None self.shape = 'rectangle' self.enable_title_bar = True self.enable_close_button = True self.title_bar_height = 28 self.title_bar_close_button_width = self.title_bar_height # UI elements self.window_element_container = None # type: Union[UIContainer, None] self.title_bar = None # type: Union[UIButton, None] self.close_window_button = None # type: Union[UIButton, None] self.rebuild_from_changed_theme_data() self.window_stack = self.ui_manager.get_window_stack() self.window_stack.add_new_window(self) def set_blocking(self, state: bool): """ Sets whether this window being open should block clicks to the rest of the UI or not. Defaults to False. :param state: True if this window should block mouse clicks. """ self.is_blocking = state def set_minimum_dimensions(self, dimensions: Union[pygame.math.Vector2, Tuple[int, int], Tuple[float, float]]): """ If this window is resizable, then the dimensions we set here will be the minimum that users can change the window to. They are also used as the minimum size when 'set_dimensions' is called. :param dimensions: The new minimum dimension for the window. """ self.minimum_dimensions = (min(self.ui_container.rect.width, int(dimensions[0])), min(self.ui_container.rect.height, int(dimensions[1]))) if ((self.rect.width < self.minimum_dimensions[0]) or (self.rect.height < self.minimum_dimensions[1])): new_width = max(self.minimum_dimensions[0], self.rect.width) new_height = max(self.minimum_dimensions[1], self.rect.height) self.set_dimensions((new_width, new_height)) def set_dimensions(self, dimensions: Union[pygame.math.Vector2, Tuple[int, int], Tuple[float, float]]): """ Set the size of this window and then re-sizes and shifts the contents of the windows container to fit the new size. :param dimensions: The new dimensions to set. """ # clamp to minimum dimensions and container size dimensions = (min(self.ui_container.rect.width, max(self.minimum_dimensions[0], int(dimensions[0]))), min(self.ui_container.rect.height, max(self.minimum_dimensions[1], int(dimensions[1])))) # Don't use a basic gate on this set dimensions method because the container may be a # different size to the window super().set_dimensions(dimensions) if self._window_root_container is not None: new_container_dimensions = (self.relative_rect.width - (2 * self.shadow_width), self.relative_rect.height - (2 * self.shadow_width)) if new_container_dimensions != self._window_root_container.relative_rect.size: self._window_root_container.set_dimensions( new_container_dimensions) container_pos = (self.relative_rect.x + self.shadow_width, self.relative_rect.y + self.shadow_width) self._window_root_container.set_relative_position( container_pos) def set_relative_position(self, position: Union[pygame.math.Vector2, Tuple[int, int], Tuple[float, float]]): """ Method to directly set the relative rect position of an element. :param position: The new position to set. """ super().set_relative_position(position) if self._window_root_container is not None: container_pos = (self.relative_rect.x + self.shadow_width, self.relative_rect.y + self.shadow_width) self._window_root_container.set_relative_position(container_pos) def set_position(self, position: Union[pygame.math.Vector2, Tuple[int, int], Tuple[float, float]]): """ Method to directly set the absolute screen rect position of an element. :param position: The new position to set. """ super().set_position(position) if self._window_root_container is not None: container_pos = (self.relative_rect.x + self.shadow_width, self.relative_rect.y + self.shadow_width) self._window_root_container.set_relative_position(container_pos) def process_event(self, event: pygame.event.Event) -> bool: """ Handles resizing & closing windows. Gives UI Windows access to pygame events. Derived windows should super() call this class if they implement their own process_event method. :param event: The event to process. :return bool: Return True if this element should consume this event and not pass it to the rest of the UI. """ consumed_event = False if self.is_blocking and event.type == pygame.MOUSEBUTTONDOWN: consumed_event = True if (self is not None and event.type == pygame.MOUSEBUTTONDOWN and event.button in [ pygame.BUTTON_LEFT, pygame.BUTTON_MIDDLE, pygame.BUTTON_RIGHT ]): scaled_mouse_pos = self.ui_manager.calculate_scaled_mouse_position( event.pos) edge_hovered = (self.edge_hovering[0] or self.edge_hovering[1] or self.edge_hovering[2] or self.edge_hovering[3]) if (self.is_enabled and event.button == pygame.BUTTON_LEFT and edge_hovered): self.resizing_mode_active = True self.start_resize_point = scaled_mouse_pos self.start_resize_rect = self.rect.copy() consumed_event = True elif self.hover_point(scaled_mouse_pos[0], scaled_mouse_pos[1]): consumed_event = True if (self is not None and event.type == pygame.MOUSEBUTTONUP and event.button == pygame.BUTTON_LEFT and self.resizing_mode_active): self.resizing_mode_active = False if (event.type == pygame.USEREVENT and event.user_type == UI_BUTTON_PRESSED and event.ui_element == self.close_window_button): self.kill() return consumed_event def check_clicked_inside_or_blocking(self, event: pygame.event.Event) -> bool: """ A quick event check outside of the normal event processing so that this window is brought to the front of the window stack if we click on any of the elements contained within it. :param event: The event to check. :return: returns True if the event represents a click inside this window or the window is blocking. """ consumed_event = False if self.is_blocking and event.type == pygame.MOUSEBUTTONDOWN: consumed_event = True if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: scaled_mouse_pos = self.ui_manager.calculate_scaled_mouse_position( event.pos) if self.hover_point(scaled_mouse_pos[0], scaled_mouse_pos[1]) or ( self.edge_hovering[0] or self.edge_hovering[1] or self.edge_hovering[2] or self.edge_hovering[3]): if self.is_enabled and self.bring_to_front_on_focused: self.window_stack.move_window_to_front(self) consumed_event = True return consumed_event def update(self, time_delta: float): """ A method called every update cycle of our application. Designed to be overridden by derived classes but also has a little functionality to make sure the window's layer 'thickness' is accurate and to handle window resizing. :param time_delta: time passed in seconds between one call to this method and the next. """ super().update(time_delta) # This is needed to keep the window in sync with the container after adding elements to it if self._window_root_container.layer_thickness != self.layer_thickness: self.layer_thickness = self._window_root_container.layer_thickness if self.title_bar is not None: if self.title_bar.held: mouse_x, mouse_y = self.ui_manager.get_mouse_position() if not self.grabbed_window: self.window_stack.move_window_to_front(self) self.grabbed_window = True self.starting_grab_difference = (mouse_x - self.rect.x, mouse_y - self.rect.y) current_grab_difference = (mouse_x - self.rect.x, mouse_y - self.rect.y) adjustment_required = (current_grab_difference[0] - self.starting_grab_difference[0], current_grab_difference[1] - self.starting_grab_difference[1]) self.set_relative_position( (self.relative_rect.x + adjustment_required[0], self.relative_rect.y + adjustment_required[1])) else: self.grabbed_window = False if self.resizing_mode_active: self._update_drag_resizing() def _update_drag_resizing(self): """ Re-sizes a window that is being dragged around its the edges by the mouse. """ x_pos = self.rect.left y_pos = self.rect.top x_dimension = self.rect.width y_dimension = self.rect.height mouse_x, mouse_y = self.ui_manager.get_mouse_position() x_diff = mouse_x - self.start_resize_point[0] y_diff = mouse_y - self.start_resize_point[1] if y_dimension >= self.minimum_dimensions[1]: y_pos = self.start_resize_rect.y y_dimension = self.start_resize_rect.height if self.edge_hovering[1]: y_dimension = self.start_resize_rect.height - y_diff y_pos = self.start_resize_rect.y + y_diff elif self.edge_hovering[3]: y_dimension = self.start_resize_rect.height + y_diff if y_dimension < self.minimum_dimensions[1]: if y_diff > 0: y_pos = self.rect.bottom - self.minimum_dimensions[1] else: y_pos = self.rect.top if x_dimension >= self.minimum_dimensions[0]: x_pos = self.start_resize_rect.x x_dimension = self.start_resize_rect.width if self.edge_hovering[0]: x_dimension = self.start_resize_rect.width - x_diff x_pos = self.start_resize_rect.x + x_diff elif self.edge_hovering[2]: x_dimension = self.start_resize_rect.width + x_diff if x_dimension < self.minimum_dimensions[0]: if x_diff > 0: x_pos = self.rect.right - self.minimum_dimensions[0] else: x_pos = self.rect.left x_dimension = max(self.minimum_dimensions[0], min(self.ui_container.rect.width, x_dimension)) y_dimension = max(self.minimum_dimensions[1], min(self.ui_container.rect.height, y_dimension)) self.set_position((x_pos, y_pos)) self.set_dimensions((x_dimension, y_dimension)) def get_container(self) -> IUIContainerInterface: """ Returns the container that should contain all the UI elements in this window. :return UIContainer: The window's container. """ return self.window_element_container def can_hover(self) -> bool: """ Called to test if this window can be hovered. """ return not (self.resizing_mode_active or (self.title_bar is not None and self.title_bar.held)) # noinspection PyUnusedLocal def check_hover(self, time_delta: float, hovered_higher_element: bool) -> bool: """ For the window the only hovering we care about is the edges if this is a resizable window. :param time_delta: time passed in seconds between one call to this method and the next. :param hovered_higher_element: Have we already hovered an element/window above this one. """ hovered = False if not self.resizing_mode_active: self.edge_hovering = [False, False, False, False] if self.alive() and self.can_hover( ) and not hovered_higher_element and self.resizable: mouse_x, mouse_y = self.ui_manager.get_mouse_position() # Build a temporary rect just a little bit larger than our container rect. resize_rect = pygame.Rect( self._window_root_container.rect.left - 4, self._window_root_container.rect.top - 4, self._window_root_container.rect.width + 8, self._window_root_container.rect.height + 8) if resize_rect.collidepoint(mouse_x, mouse_y): if resize_rect.right > mouse_x > resize_rect.right - 6: self.edge_hovering[2] = True hovered = True if resize_rect.left + 6 > mouse_x > resize_rect.left: self.edge_hovering[0] = True hovered = True if resize_rect.bottom > mouse_y > resize_rect.bottom - 6: self.edge_hovering[3] = True hovered = True if resize_rect.top + 6 > mouse_y > resize_rect.top: self.edge_hovering[1] = True hovered = True elif self.resizing_mode_active: hovered = True if self.is_blocking: hovered = True if hovered: hovered_higher_element = True self.hovered = True else: self.hovered = False return hovered_higher_element def get_top_layer(self) -> int: """ Returns the 'highest' layer used by this window so that we can correctly place other windows on top of it. :return: The top layer for this window as a number (greater numbers are higher layers). """ return self._layer + self.layer_thickness def change_layer(self, new_layer: int): """ Move this window, and it's contents, to a new layer in the UI. :param new_layer: The layer to move to. """ if new_layer != self._layer: super().change_layer(new_layer) if self._window_root_container is not None: self._window_root_container.change_layer(new_layer) if self._window_root_container.layer_thickness != self.layer_thickness: self.layer_thickness = self._window_root_container.layer_thickness def kill(self): """ Overrides the basic kill() method of a pygame sprite so that we also kill all the UI elements in this window, and remove if from the window stack. """ window_close_event = pygame.event.Event( pygame.USEREVENT, { 'user_type': UI_WINDOW_CLOSE, 'ui_element': self, 'ui_object_id': self.most_specific_combined_id }) pygame.event.post(window_close_event) self.window_stack.remove_window(self) self._window_root_container.kill() super().kill() def rebuild(self): """ Rebuilds the window when the theme has changed. """ if self._window_root_container is None: self._window_root_container = UIContainer( pygame.Rect( self.relative_rect.x + self.shadow_width, self.relative_rect.y + self.shadow_width, self.relative_rect.width - (2 * self.shadow_width), self.relative_rect.height - (2 * self.shadow_width)), manager=self.ui_manager, starting_height=1, is_window_root_container=True, container=None, parent_element=self, object_id="#window_root_container", visible=self.visible) if self.window_element_container is None: window_container_rect = pygame.Rect( self.border_width, self.title_bar_height, (self._window_root_container.relative_rect.width - (2 * self.border_width)), (self._window_root_container.relative_rect.height - (self.title_bar_height + self.border_width))) self.window_element_container = UIContainer( window_container_rect, self.ui_manager, starting_height=0, container=self._window_root_container, parent_element=self, object_id="#window_element_container", anchors={ 'top': 'top', 'bottom': 'bottom', 'left': 'left', 'right': 'right' }) theming_parameters = { 'normal_bg': self.background_colour, 'normal_border': self.border_colour, 'border_width': self.border_width, 'shadow_width': self.shadow_width, 'shape_corner_radius': self.shape_corner_radius } if self.shape == 'rectangle': self.drawable_shape = RectDrawableShape(self.rect, theming_parameters, ['normal'], self.ui_manager) elif self.shape == 'rounded_rectangle': self.drawable_shape = RoundedRectangleShape( self.rect, theming_parameters, ['normal'], self.ui_manager) self.set_image(self.drawable_shape.get_fresh_surface()) self.set_dimensions(self.relative_rect.size) if self.window_element_container is not None: element_container_width = ( self._window_root_container.relative_rect.width - (2 * self.border_width)) element_container_height = ( self._window_root_container.relative_rect.height - (self.title_bar_height + self.border_width)) self.window_element_container.set_dimensions( (element_container_width, element_container_height)) self.window_element_container.set_relative_position( (self.border_width, self.title_bar_height)) if self.enable_title_bar: if self.title_bar is not None: self.title_bar.set_dimensions( (self._window_root_container.relative_rect.width - self.title_bar_close_button_width, self.title_bar_height)) else: title_bar_width = ( self._window_root_container.relative_rect.width - self.title_bar_close_button_width) self.title_bar = UIButton( relative_rect=pygame.Rect(0, 0, title_bar_width, self.title_bar_height), text=self.window_display_title, manager=self.ui_manager, container=self._window_root_container, parent_element=self, object_id='#title_bar', anchors={ 'top': 'top', 'bottom': 'top', 'left': 'left', 'right': 'right' }) self.title_bar.set_hold_range((100, 100)) if self.enable_close_button: if self.close_window_button is not None: close_button_pos = (-self.title_bar_close_button_width, 0) self.close_window_button.set_dimensions( (self.title_bar_close_button_width, self.title_bar_height)) self.close_window_button.set_relative_position( close_button_pos) else: close_rect = pygame.Rect( (-self.title_bar_close_button_width, 0), (self.title_bar_close_button_width, self.title_bar_height)) self.close_window_button = UIButton( relative_rect=close_rect, text='╳', manager=self.ui_manager, container=self._window_root_container, parent_element=self, object_id='#close_button', anchors={ 'top': 'top', 'bottom': 'top', 'left': 'right', 'right': 'right' }) else: if self.close_window_button is not None: self.close_window_button.kill() self.close_window_button = None else: if self.title_bar is not None: self.title_bar.kill() self.title_bar = None if self.close_window_button is not None: self.close_window_button.kill() self.close_window_button = None def rebuild_from_changed_theme_data(self): """ Called by the UIManager to check the theming data and rebuild whatever needs rebuilding for this element when the theme data has changed. """ super().rebuild_from_changed_theme_data() has_any_changed = False if self._check_misc_theme_data_changed( attribute_name='shape', default_value='rectangle', casting_func=str, allowed_values=['rectangle', 'rounded_rectangle']): has_any_changed = True if self._check_shape_theming_changed(defaults={ 'border_width': 1, 'shadow_width': 15, 'shape_corner_radius': 2 }): has_any_changed = True background_colour = self.ui_theme.get_colour_or_gradient( 'dark_bg', self.combined_element_ids) if background_colour != self.background_colour: self.background_colour = background_colour has_any_changed = True border_colour = self.ui_theme.get_colour_or_gradient( 'normal_border', self.combined_element_ids) if border_colour != self.border_colour: self.border_colour = border_colour has_any_changed = True if self._check_title_bar_theming_changed(): has_any_changed = True if has_any_changed: self.rebuild() def _check_title_bar_theming_changed(self): """ Check to see if any theming parameters for the title bar have changed. :return: True if any of the theming parameters have changed. """ has_any_changed = False def parse_to_bool(str_data: str): return bool(int(str_data)) if self._check_misc_theme_data_changed( attribute_name='enable_title_bar', default_value=True, casting_func=parse_to_bool): has_any_changed = True if self.enable_title_bar: if self._check_misc_theme_data_changed( attribute_name='title_bar_height', default_value=28, casting_func=int): has_any_changed = True self.title_bar_close_button_width = self.title_bar_height if self._check_misc_theme_data_changed( attribute_name='enable_close_button', default_value=True, casting_func=parse_to_bool): has_any_changed = True if not self.enable_close_button: self.title_bar_close_button_width = 0 else: self.title_bar_height = 0 return has_any_changed def should_use_window_edge_resize_cursor(self) -> bool: """ Returns true if this window is in a state where we should display one of the resizing cursors :return: True if a resizing cursor is needed. """ return (self.hovered or self.resizing_mode_active) and any( self.edge_hovering) def get_hovering_edge_id(self) -> str: """ Gets the ID of the combination of edges we are hovering for use by the cursor system. :return: a string containing the edge combination ID (e.g. xy,yx,xl,xr,yt,yb) """ if ((self.edge_hovering[0] and self.edge_hovering[1]) or (self.edge_hovering[2] and self.edge_hovering[3])): return 'xy' elif ((self.edge_hovering[0] and self.edge_hovering[3]) or (self.edge_hovering[2] and self.edge_hovering[1])): return 'yx' elif self.edge_hovering[0]: return 'xl' elif self.edge_hovering[2]: return 'xr' elif self.edge_hovering[3]: return 'yb' else: return 'yt' def on_moved_to_front(self): """ Called when a window is moved to the front of the stack. """ window_front_event = pygame.event.Event( pygame.USEREVENT, { 'user_type': UI_WINDOW_MOVED_TO_FRONT, 'ui_element': self, 'ui_object_id': self.most_specific_combined_id }) pygame.event.post(window_front_event) def set_display_title(self, new_title: str): """ Set the title of the window. :param new_title: The title to set. """ self.window_display_title = new_title self.title_bar.set_text(self.window_display_title) def disable(self): """ Disables the window and it's contents so it is no longer interactive. """ if self.is_enabled: self.is_enabled = False self._window_root_container.disable() def enable(self): """ Enables the window and it's contents so it is interactive again. """ if not self.is_enabled: self.is_enabled = True self._window_root_container.enable() def show(self): """ In addition to the base UIElement.show() - show the _window_root_container which will propagate and show all the children. """ super().show() self._window_root_container.show() def hide(self): """ In addition to the base UIElement.hide() - hide the _window_root_container which will propagate and hide all the children. """ super().hide() self._window_root_container.hide()