class MainMenu(BaseAppState): def __init__(self, ui_manager: pygame_gui.UIManager, state_manger): super().__init__('main_menu', 'select_level', state_manger) self.ui_manager = ui_manager self.background_image = pygame.image.load( "images/menu_background.png").convert() self.title_label = None self.play_game_button = None def start(self): self.title_label = UILabel(pygame.Rect((87, 40), (850, 178)), "Turret Warfare", self.ui_manager, object_id="#game_title") self.play_game_button = UIButton( pygame.Rect((437, 515), (150, 35)), "Start Game", self.ui_manager, tool_tip_text="<b>Click to Start.</b>") def end(self): self.title_label.kill() self.play_game_button.kill() def run(self, surface, time_delta): for event in pygame.event.get(): if event.type == QUIT: self.set_target_state_name('quit') self.trigger_transition() if event.type == pygame.KEYDOWN: if event.key == pygame.K_ESCAPE: self.set_target_state_name('quit') self.trigger_transition() self.ui_manager.process_events(event) if event.type == pygame.USEREVENT: if event.user_type == "ui_button_pressed": if event.ui_element == self.play_game_button: self.set_target_state_name('select_level') self.trigger_transition() self.ui_manager.update(time_delta) surface.blit(self.background_image, (0, 0)) # draw the background self.ui_manager.draw_ui(surface)
def test_kill(self, _init_pygame, default_ui_manager): button = UIButton(relative_rect=pygame.Rect(100, 100, 150, 30), text="Test Button", tool_tip_text="This is a test of the button's tool tip functionality.", manager=default_ui_manager) # create the tool tip button.hover_time = 9999.0 button.while_hovering(0.01, pygame.math.Vector2(150.0, 115.0)) # should kill everything button.kill() assert button.alive() is False and button.tool_tip.alive() is False
class UIClosedDropDownState: """ The closed state of the drop down just displays the currently chosen option and a button that will switch the menu to the expanded state. :param drop_down_menu_ui: The UIDropDownElement this state belongs to. :param selected_option: The currently selected option. :param base_position_rect: Position and dimensions rectangle. :param open_button_width: Width of open button. :param expand_direction: Direction of expansion, 'up' or 'down'. :param manager: The UI Manager for the whole UI. :param container: The container the element is within. :param object_ids: The object IDs for the drop down UI element. :param element_ids: The element IDs for the drop down UI element. :param visible: Whether the element is visible by default. Warning - container visibility may override this. """ def __init__(self, drop_down_menu_ui: 'UIDropDownMenu', selected_option: str, base_position_rect: Union[pygame.Rect, None], open_button_width: int, expand_direction: Union[str, None], manager: IUIManagerInterface, container: IContainerLikeInterface, object_ids: Union[List[Union[str, None]], None], element_ids: Union[List[str], None], visible: int = 1): self.drop_down_menu_ui = drop_down_menu_ui self.selected_option_button = None self.open_button = None self.selected_option = selected_option self.base_position_rect = base_position_rect self.expand_direction = expand_direction self.ui_manager = manager self.ui_container = container self.element_ids = element_ids self.object_ids = object_ids self.open_button_width = open_button_width self.should_transition = False self.target_state = 'expanded' self.visible = visible self.active_buttons = [] def disable(self): """ Disables the closed state so that it is no longer interactive. """ self.selected_option_button.disable() if self.open_button is not None: self.open_button.disable() self.drop_down_menu_ui.drawable_shape.set_active_state('disabled') def enable(self): """ Re-enables the closed state so we can once again interact with it. """ self.selected_option_button.enable() if self.open_button is not None: self.open_button.enable() self.drop_down_menu_ui.drawable_shape.set_active_state('normal') def rebuild(self): """ Rebuild the closed state from theming parameters and dimensions. """ theming_parameters = { 'normal_bg': self.drop_down_menu_ui.background_colour, 'normal_border': self.drop_down_menu_ui.border_colour, 'disabled_bg': self.drop_down_menu_ui.disabled_background_colour, 'disabled_border': self.drop_down_menu_ui.disabled_border_colour, 'border_width': self.drop_down_menu_ui.border_width, 'shadow_width': self.drop_down_menu_ui.shadow_width, 'shape_corner_radius': self.drop_down_menu_ui.shape_corner_radius } if self.drop_down_menu_ui.shape == 'rectangle': self.drop_down_menu_ui.drawable_shape = RectDrawableShape( self.drop_down_menu_ui.rect, theming_parameters, ['normal', 'disabled'], self.ui_manager) elif self.drop_down_menu_ui.shape == 'rounded_rectangle': shape_rect = self.drop_down_menu_ui.rect self.drop_down_menu_ui.drawable_shape = RoundedRectangleShape( shape_rect, theming_parameters, ['normal', 'disabled'], self.ui_manager) self.drop_down_menu_ui.set_image( self.drop_down_menu_ui.drawable_shape.get_fresh_surface()) # extra if self.open_button is not None: expand_button_symbol = '▼' if self.expand_direction is not None: if self.expand_direction == 'up': expand_button_symbol = '▲' elif self.expand_direction == 'down': expand_button_symbol = '▼' self.open_button.set_text(expand_button_symbol) def start(self, should_rebuild: bool = True): """ Called each time we enter the closed state. It creates the necessary elements, the selected option and the open button. """ if should_rebuild: self.rebuild() self.should_transition = False border_and_shadow = (self.drop_down_menu_ui.shadow_width + self.drop_down_menu_ui.border_width) self.active_buttons = [] self.selected_option_button = UIButton( pygame.Rect( (border_and_shadow, border_and_shadow), (self.base_position_rect.width - self.open_button_width, self.base_position_rect.height)), self.selected_option, self.ui_manager, self.ui_container, starting_height=2, parent_element=self.drop_down_menu_ui, object_id='#selected_option', visible=self.visible) self.drop_down_menu_ui.join_focus_sets(self.selected_option_button) self.active_buttons.append(self.selected_option_button) if self.open_button_width > 0: open_button_x = (border_and_shadow + self.base_position_rect.width - self.open_button_width) expand_button_symbol = '▼' if self.expand_direction is not None: if self.expand_direction == 'up': expand_button_symbol = '▲' elif self.expand_direction == 'down': expand_button_symbol = '▼' self.open_button = UIButton(pygame.Rect( (open_button_x, border_and_shadow), (self.open_button_width, self.base_position_rect.height)), expand_button_symbol, self.ui_manager, self.ui_container, starting_height=2, parent_element=self.drop_down_menu_ui, object_id='#expand_button', visible=self.visible) self.drop_down_menu_ui.join_focus_sets(self.open_button) self.active_buttons.append(self.open_button) def finish(self): """ Called when we leave the closed state. Kills the open button and the selected option button. """ self.selected_option_button.kill() if self.open_button is not None: self.open_button.kill() def process_event(self, event: pygame.event.Event) -> bool: """ Processes events for the closed state of the drop down. :param event: The event to process. :return: Return True if we want to consume this event so it is not passed on to the rest of the UI. """ if event.type == UI_BUTTON_PRESSED and event.ui_element in self.active_buttons: self.should_transition = True return False def update_position(self): """ Update the position of all the button elements in the closed drop down state. Used when the position of the drop down has been altered directly, rather than when it has been moved as a consequence of it's container being moved. """ # update the base position rect border_and_shadow = (self.drop_down_menu_ui.shadow_width + self.drop_down_menu_ui.border_width) self.base_position_rect.x = self.drop_down_menu_ui.relative_rect.x + border_and_shadow self.base_position_rect.y = self.drop_down_menu_ui.relative_rect.y + border_and_shadow # update all the ui elements that depend on the base position self.selected_option_button.set_relative_position( (border_and_shadow, border_and_shadow)) if self.open_button is not None: open_button_x = (border_and_shadow + self.base_position_rect.width - self.open_button_width) self.open_button.set_relative_position( (open_button_x, self.base_position_rect.y)) def update_dimensions(self): """ Update the dimensions of all the button elements in the closed drop down state. Used when the dimensions of the drop down have been altered. """ # update the base position rect border_and_shadow = (self.drop_down_menu_ui.shadow_width + self.drop_down_menu_ui.border_width) self.base_position_rect.width = ( self.drop_down_menu_ui.relative_rect.width - (2 * border_and_shadow)) self.base_position_rect.height = ( self.drop_down_menu_ui.relative_rect.height - (2 * border_and_shadow)) # update all the ui elements that depend on the base position rect self.selected_option_button.set_dimensions( (self.base_position_rect.width - self.open_button_width, self.base_position_rect.height)) if self.open_button is not None: open_button_x = (border_and_shadow + self.base_position_rect.width - self.open_button_width) self.open_button.set_dimensions( (self.open_button_width, self.base_position_rect.height)) self.open_button.set_relative_position( (open_button_x, border_and_shadow)) def on_fresh_drawable_shape_ready(self): """ Called by an element's drawable shape when it has a new image surface ready for use, normally after a rebuilding/redrawing of some kind. In this case the result is to set the UI element's image to the new surface. """ self.drop_down_menu_ui.set_image( self.drop_down_menu_ui.drawable_shape.get_fresh_surface()) def show(self): """ Show selected_option_button and open_button. """ self.visible = 1 if self.open_button is not None: self.open_button.show() if self.selected_option_button is not None: self.selected_option_button.show() def hide(self): """ Hide selected_option_button and open_button. """ self.visible = 0 if self.open_button is not None: self.open_button.hide() if self.selected_option_button is not None: self.selected_option_button.hide()
class UIExpandedDropDownState: """ The expanded state of the drop down displays the currently chosen option, all the available options and a button to close the menu and return to the closed state. Picking an option will also close the menu. :param drop_down_menu_ui: The UIDropDownElement this state belongs to. :param options_list: The list of options in this drop down. :param selected_option: The currently selected option. :param base_position_rect: Position and dimensions rectangle. :param close_button_width: Width of close button. :param expand_direction: Direction of expansion, 'up' or 'down'. :param manager: The UI Manager for the whole UI. :param container: The container the element is within. :param object_ids: The object IDs for the drop down UI element. :param element_ids: The element IDs for the drop down UI element. """ def __init__(self, drop_down_menu_ui: 'UIDropDownMenu', options_list: List[str], selected_option: str, base_position_rect: Union[pygame.Rect, None], close_button_width: int, expand_direction: Union[str, None], manager: IUIManagerInterface, container: IContainerLikeInterface, object_ids: Union[List[Union[str, None]], None], element_ids: Union[List[str], None]): self.drop_down_menu_ui = drop_down_menu_ui self.options_list = options_list self.selected_option = selected_option self.base_position_rect = base_position_rect self.expand_direction = expand_direction self.ui_manager = manager self.ui_container = container self.element_ids = element_ids self.object_ids = object_ids # sizing variables self.options_list_height = 0 self.option_list_y_pos = 0 self.close_button_width = close_button_width # UI elements self.selected_option_button = None self.close_button = None self.options_selection_list = None # state transitioning self.should_transition = False self.target_state = 'closed' self.active_buttons = [] def rebuild(self): """ Rebuild the state from theming parameters and dimensions. """ theming_parameters = { 'normal_bg': self.drop_down_menu_ui.background_colour, 'normal_border': self.drop_down_menu_ui.border_colour, 'border_width': self.drop_down_menu_ui.border_width, 'shadow_width': self.drop_down_menu_ui.shadow_width, 'shape_corner_radius': self.drop_down_menu_ui.shape_corner_radius } shape_rect = self.drop_down_menu_ui.relative_rect if self.drop_down_menu_ui.shape == 'rectangle': self.drop_down_menu_ui.drawable_shape = RectDrawableShape( shape_rect, theming_parameters, ['normal'], self.ui_manager) elif self.drop_down_menu_ui.shape == 'rounded_rectangle': self.drop_down_menu_ui.drawable_shape = RoundedRectangleShape( shape_rect, theming_parameters, ['normal'], self.ui_manager) self.on_fresh_drawable_shape_ready() # extra if self.close_button is not None: expand_button_symbol = '▼' if self.expand_direction is not None: if self.expand_direction == 'up': expand_button_symbol = '▲' elif self.expand_direction == 'down': expand_button_symbol = '▼' self.close_button.set_text(expand_button_symbol) def start(self, should_rebuild: bool = True): """ Called each time we enter the expanded state. It creates the necessary elements, the selected option, all the other available options and the close button. """ self.should_transition = False border_and_shadow = (self.drop_down_menu_ui.shadow_width + self.drop_down_menu_ui.border_width) self.active_buttons = [] self.selected_option_button = UIButton( pygame.Rect( (border_and_shadow, border_and_shadow), (self.base_position_rect.width - self.close_button_width, self.base_position_rect.height)), self.selected_option, self.ui_manager, self.ui_container, starting_height=2, parent_element=self.drop_down_menu_ui, object_id=ObjectID('#selected_option', None)) self.drop_down_menu_ui.join_focus_sets(self.selected_option_button) self.active_buttons.append(self.selected_option_button) expand_button_symbol = '▼' list_object_id = '#drop_down_options_list' list_object_ids = self.drop_down_menu_ui.object_ids[:] list_object_ids.append(list_object_id) list_class_ids = self.drop_down_menu_ui.class_ids[:] list_class_ids.append(None) list_element_ids = self.drop_down_menu_ui.element_ids[:] list_element_ids.append('selection_list') final_ids = self.ui_manager.get_theme().build_all_combined_ids( list_element_ids, list_class_ids, list_object_ids) self._calculate_options_list_sizes(final_ids) if self.expand_direction is not None: if self.expand_direction == 'up': expand_button_symbol = '▲' if self.drop_down_menu_ui.expansion_height_limit is None: self.drop_down_menu_ui.expansion_height_limit = self.base_position_rect.top self.options_list_height = min( self.options_list_height, self.drop_down_menu_ui.expansion_height_limit) self.option_list_y_pos = self.base_position_rect.top - self.options_list_height elif self.expand_direction == 'down': expand_button_symbol = '▼' if self.drop_down_menu_ui.expansion_height_limit is None: height_limit = (self.drop_down_menu_ui.ui_container. relative_rect.height - self.base_position_rect.bottom) self.drop_down_menu_ui.expansion_height_limit = height_limit self.options_list_height = min( self.options_list_height, self.drop_down_menu_ui.expansion_height_limit) self.option_list_y_pos = self.base_position_rect.bottom if self.close_button_width > 0: close_button_x = (border_and_shadow + self.base_position_rect.width - self.close_button_width) self.close_button = UIButton(pygame.Rect( (close_button_x, border_and_shadow), (self.close_button_width, self.base_position_rect.height)), expand_button_symbol, self.ui_manager, self.ui_container, starting_height=2, parent_element=self.drop_down_menu_ui, object_id='#expand_button') self.drop_down_menu_ui.join_focus_sets(self.close_button) self.active_buttons.append(self.close_button) list_rect = pygame.Rect(self.drop_down_menu_ui.relative_rect.left, self.option_list_y_pos, (self.drop_down_menu_ui.relative_rect.width - self.close_button_width), self.options_list_height) self.options_selection_list = UISelectionList( list_rect, starting_height=3, item_list=self.options_list, allow_double_clicks=False, manager=self.ui_manager, parent_element=self.drop_down_menu_ui, container=self.drop_down_menu_ui.ui_container, anchors=self.drop_down_menu_ui.anchors, object_id='#drop_down_options_list') self.drop_down_menu_ui.join_focus_sets(self.options_selection_list) if should_rebuild: self.rebuild() def _calculate_options_list_sizes(self, final_ids): try: list_shadow_width = int(self.ui_manager.get_theme().get_misc_data( 'shadow_width', final_ids)) except (LookupError, ValueError): list_shadow_width = 2 try: list_border_width = int(self.ui_manager.get_theme().get_misc_data( 'border_width', final_ids)) except (LookupError, ValueError): list_border_width = 1 try: list_item_height = int(self.ui_manager.get_theme().get_misc_data( 'list_item_height', final_ids)) except (LookupError, ValueError): list_item_height = 20 options_list_border_and_shadow = list_shadow_width + list_border_width self.options_list_height = ( (list_item_height * len(self.options_list)) + (2 * options_list_border_and_shadow)) self.option_list_y_pos = 0 def finish(self): """ cleans everything up upon exiting the expanded menu state. """ self.options_selection_list.kill() self.selected_option_button.kill() if self.close_button is not None: self.close_button.kill() def process_event(self, event: pygame.event.Event) -> bool: """ Processes events for the closed state of the drop down. :param event: The event to process. :return: Return True if we want to consume this event so it is not passed on to the rest of the UI. """ if event.type == UI_BUTTON_PRESSED and event.ui_element in self.active_buttons: self.should_transition = True if (event.type == UI_SELECTION_LIST_NEW_SELECTION and event.ui_element == self.options_selection_list): selection = self.options_selection_list.get_single_selection() self.drop_down_menu_ui.selected_option = selection self.should_transition = True # old event - to be removed in 0.8.0 event_data = { 'user_type': OldType(UI_DROP_DOWN_MENU_CHANGED), 'text': self.drop_down_menu_ui.selected_option, 'ui_element': self.drop_down_menu_ui, 'ui_object_id': self.drop_down_menu_ui.most_specific_combined_id } pygame.event.post(pygame.event.Event(pygame.USEREVENT, event_data)) # new event event_data = { 'text': self.drop_down_menu_ui.selected_option, 'ui_element': self.drop_down_menu_ui, 'ui_object_id': self.drop_down_menu_ui.most_specific_combined_id } pygame.event.post( pygame.event.Event(UI_DROP_DOWN_MENU_CHANGED, event_data)) return False # don't consume any events def update_position(self): """ Update the position of all the button elements in the open drop down state. Used when the position of the drop down has been altered directly, rather than when it has been moved as a consequence of it's container being moved. """ # update the base position rect border_and_shadow = (self.drop_down_menu_ui.shadow_width + self.drop_down_menu_ui.border_width) self.base_position_rect.x = self.drop_down_menu_ui.relative_rect.x + border_and_shadow self.base_position_rect.y = self.drop_down_menu_ui.relative_rect.y + border_and_shadow # update all the ui elements that depend on the base position self.selected_option_button.set_relative_position( (border_and_shadow, border_and_shadow)) list_post = (self.drop_down_menu_ui.relative_rect.left, self.option_list_y_pos) self.options_selection_list.set_relative_position(list_post) if self.close_button is not None: close_button_x = (border_and_shadow + self.base_position_rect.width - self.close_button_width) self.close_button.set_relative_position( [close_button_x, border_and_shadow]) def update_dimensions(self): """ Update the dimensions of all the button elements in the closed drop down state. Used when the dimensions of the drop down have been altered. """ # update the base position rect border_and_shadow = (self.drop_down_menu_ui.shadow_width + self.drop_down_menu_ui.border_width) self.base_position_rect.width = ( self.drop_down_menu_ui.relative_rect.width - (2 * border_and_shadow)) self.base_position_rect.height = ( self.drop_down_menu_ui.relative_rect.height - (2 * border_and_shadow)) if self.expand_direction is not None: if self.expand_direction == 'up': self.options_list_height = min( self.options_list_height, self.drop_down_menu_ui.expansion_height_limit) self.option_list_y_pos = self.base_position_rect.top - self.options_list_height elif self.expand_direction == 'down': self.options_list_height = min( self.options_list_height, self.drop_down_menu_ui.expansion_height_limit) self.option_list_y_pos = self.base_position_rect.bottom # update all the ui elements that depend on the base position rect self.selected_option_button.set_dimensions( (self.base_position_rect.width - self.close_button_width, self.base_position_rect.height)) self.options_selection_list.set_dimensions( ((self.drop_down_menu_ui.relative_rect.width - self.close_button_width), self.options_list_height)) list_pos = (self.drop_down_menu_ui.relative_rect.left, self.option_list_y_pos) self.options_selection_list.set_relative_position(list_pos) if self.close_button is not None: close_button_x = (border_and_shadow + self.base_position_rect.width - self.close_button_width) self.close_button.set_dimensions( (self.close_button_width, self.base_position_rect.height)) self.close_button.set_relative_position( (close_button_x, border_and_shadow)) def on_fresh_drawable_shape_ready(self): """ Called by an element's drawable shape when it has a new image surface ready for use, normally after a rebuilding/redrawing of some kind. In this case the result is to set the UI element's image to the new surface. """ self.drop_down_menu_ui.set_image( self.drop_down_menu_ui.drawable_shape.get_fresh_surface()) def hide(self): """ Transition from expanded state to closed state. """ self.should_transition = True
class UIClosedDropDownState: """ The closed state of the drop down just displays the currently chosen option and a button that will switch the menu to the expanded state. """ def __init__(self, drop_down_menu_ui, selected_option, base_position_rect, open_button_width, expand_direction, manager, container, element_ids, object_ids): self.drop_down_menu_ui = drop_down_menu_ui self.selected_option_button = None self.open_button = None self.selected_option = selected_option self.base_position_rect = base_position_rect self.expand_direction = expand_direction self.ui_manager = manager self.ui_container = container self.element_ids = element_ids self.object_ids = object_ids self.shape_type = None self.drawable_shape = None self.open_button_width = open_button_width self.should_transition = False self.target_state = 'expanded' def rebuild(self): theming_parameters = {'normal_bg': self.drop_down_menu_ui.background_colour, 'normal_border': self.drop_down_menu_ui.border_colour, 'border_width': self.drop_down_menu_ui.border_width, 'shadow_width': self.drop_down_menu_ui.shadow_width, 'shape_corner_radius': self.drop_down_menu_ui.shape_corner_radius} if self.drop_down_menu_ui.shape_type == 'rectangle': self.drawable_shape = RectDrawableShape(self.drop_down_menu_ui.rect, theming_parameters, ['normal'], self.ui_manager) elif self.drop_down_menu_ui.shape_type == 'rounded_rectangle': self.drawable_shape = RoundedRectangleShape(self.drop_down_menu_ui.rect, theming_parameters, ['normal'], self.ui_manager) self.drop_down_menu_ui.image = self.drawable_shape.get_surface('normal') # extra if self.open_button is not None: expand_button_symbol = '▼' if self.expand_direction is not None: if self.expand_direction == 'up': expand_button_symbol = '▲' elif self.expand_direction == 'down': expand_button_symbol = '▼' self.open_button.set_text(expand_button_symbol) def start(self): """ Called each time we enter the closed state. It creates the necessary elements, the selected option and the open button. """ self.rebuild() self.should_transition = False self.selected_option_button = UIButton(pygame.Rect((self.base_position_rect.x, self.base_position_rect.y), (self.base_position_rect.width - self.open_button_width, self.base_position_rect.height)), self.selected_option, self.ui_manager, self.ui_container, starting_height=2, parent_element=self.drop_down_menu_ui, object_id='#selected_option') open_button_x = self.base_position_rect.x + self.base_position_rect.width - self.open_button_width expand_button_symbol = '▼' if self.expand_direction is not None: if self.expand_direction == 'up': expand_button_symbol = '▲' elif self.expand_direction == 'down': expand_button_symbol = '▼' self.open_button = UIButton(pygame.Rect((open_button_x, self.base_position_rect.y), (self.open_button_width, self.base_position_rect.height)), expand_button_symbol, self.ui_manager, self.ui_container, starting_height=2, parent_element=self.drop_down_menu_ui, object_id='#expand_button') def finish(self): """ Called when we leave the closed state. Kills the open button and the selected option button. """ self.selected_option_button.kill() self.open_button.kill() def update(self): if self.open_button.check_pressed(): self.should_transition = True
class UIExpandedDropDownState: """ The expanded state of the drop down displays the currently chosen option, all the available options and a button to close the menu and return to the closed state. Picking an option will also close the menu. """ def __init__(self, drop_down_menu_ui, options_list, selected_option, base_position_rect, close_button_width, expand_direction, manager, container, element_ids, object_ids): self.drop_down_menu_ui = drop_down_menu_ui self.should_transition = False self.options_list = options_list self.selected_option = selected_option self.base_position_rect = base_position_rect self.selected_option_rect = None self.expand_direction = expand_direction self.ui_manager = manager self.ui_container = container self.element_ids = element_ids self.object_ids = object_ids self.rect_height_offset = 0 self.close_button_width = close_button_width self.selected_option_button = None self.close_button = None self.drawable_shape = None self.menu_buttons = [] self.should_transition = False self.target_state = 'closed' def rebuild(self): # shape for expanded drop down is a little trick because it is two rectangles, one on top of the other # forming an 'L' shape (or an inverted L if dropping down) if self.expand_direction == 'down': overall_background_rect = pygame.Rect(self.drop_down_menu_ui.rect.topleft, (self.drop_down_menu_ui.rect.width + 50, self.base_position_rect.height * (1 + len(self.options_list)) + 2 * self.drop_down_menu_ui.shadow_width + 2 * self.drop_down_menu_ui.border_width)) options_background_rect = pygame.Rect(self.drop_down_menu_ui.rect.topleft, (self.base_position_rect.width - self.close_button_width + 2 * self.drop_down_menu_ui.shadow_width + 2 * self.drop_down_menu_ui.border_width, self.base_position_rect.height * (1 + len(self.options_list)) + 2 * self.drop_down_menu_ui.shadow_width + 2 * self.drop_down_menu_ui.border_width)) self.rect_height_offset = 0 self.selected_option_rect = pygame.Rect((0, 0), self.drop_down_menu_ui.rect.size) else: # need to adjust the position of the rect so it appears in the right position self.rect_height_offset = self.base_position_rect.height * len(self.options_list) self.drop_down_menu_ui.rect.y = self.drop_down_menu_ui.rect.y - self.rect_height_offset self.drop_down_menu_ui.relative_rect.y = self.drop_down_menu_ui.relative_rect.y - self.rect_height_offset self.selected_option_rect = pygame.Rect((0, self.rect_height_offset), self.drop_down_menu_ui.rect.size) overall_background_rect = pygame.Rect(self.drop_down_menu_ui.rect.topleft, (self.drop_down_menu_ui.rect.width + 50, self.base_position_rect.height * (1 + len(self.options_list)) + 2 * self.drop_down_menu_ui.shadow_width + 2 * self.drop_down_menu_ui.border_width)) options_background_rect = pygame.Rect(self.drop_down_menu_ui.rect.topleft, (self.base_position_rect.width - self.close_button_width + 2 * self.drop_down_menu_ui.shadow_width + 2 * self.drop_down_menu_ui.border_width, self.base_position_rect.height * (1 + len(self.options_list)) + 2 * self.drop_down_menu_ui.shadow_width + 2 * self.drop_down_menu_ui.border_width)) self.drop_down_menu_ui.image = pygame.Surface(overall_background_rect.size, flags=pygame.SRCALPHA) self.drop_down_menu_ui.image.fill(pygame.Color('#00000000')) theming_parameters = {'normal_bg': self.drop_down_menu_ui.background_colour, 'normal_border': self.drop_down_menu_ui.border_colour, 'border_width': self.drop_down_menu_ui.border_width, 'shadow_width': self.drop_down_menu_ui.shadow_width, 'shape_corner_radius': self.drop_down_menu_ui.shape_corner_radius} if self.drop_down_menu_ui.shape_type == 'rectangle': drawable_shape = RectDrawableShape(self.selected_option_rect, theming_parameters, ['normal'], self.ui_manager) self.drop_down_menu_ui.image.blit(drawable_shape.get_surface('normal'), self.selected_option_rect.topleft) self.drop_down_menu_ui.image.fill(pygame.Color('#00000000'), pygame.Rect((0, 0), (options_background_rect.width - self.drop_down_menu_ui.shadow_width - self.drop_down_menu_ui.border_width, options_background_rect.height))) options_drawable_shape = RectDrawableShape(options_background_rect, theming_parameters, ['normal'], self.ui_manager) self.drop_down_menu_ui.image.blit(options_drawable_shape.get_surface('normal'), (0, 0)) elif self.drop_down_menu_ui.shape_type == 'rounded_rectangle': drawable_shape = RoundedRectangleShape(self.selected_option_rect, theming_parameters, ['normal'], self.ui_manager) self.drop_down_menu_ui.image.blit(drawable_shape.get_surface('normal'), self.selected_option_rect.topleft) self.drop_down_menu_ui.image.fill(pygame.Color('#00000000'), pygame.Rect((0, 0), (options_background_rect.width - self.drop_down_menu_ui.shadow_width - self.drop_down_menu_ui.border_width, options_background_rect.height))) options_drawable_shape = RoundedRectangleShape(options_background_rect, theming_parameters, ['normal'], self.ui_manager) self.drop_down_menu_ui.image.blit(options_drawable_shape.get_surface('normal'), (0, 0)) # extra if self.close_button is not None: expand_button_symbol = '▼' if self.expand_direction is not None: if self.expand_direction == 'up': expand_button_symbol = '▲' elif self.expand_direction == 'down': expand_button_symbol = '▼' self.close_button.set_text(expand_button_symbol) def start(self): """ Called each time we enter the expanded state. It creates the necessary elements, the selected option, all the other available options and the close button. """ self.should_transition = False option_y_pos = self.base_position_rect.y self.selected_option_button = UIButton(pygame.Rect(self.base_position_rect.topleft, (self.base_position_rect.width - self.close_button_width, self.base_position_rect.height)), self.selected_option, self.ui_manager, self.ui_container, starting_height=2, parent_element=self.drop_down_menu_ui, object_id='#selected_option') expand_button_symbol = '▼' select_button_dist_to_move = self.selected_option_button.rect.height option_button_dist_to_move = self.base_position_rect.height if self.expand_direction is not None: if self.expand_direction == 'up': expand_button_symbol = '▲' select_button_dist_to_move = -self.selected_option_button.rect.height option_button_dist_to_move = -self.base_position_rect.height elif self.expand_direction == 'down': expand_button_symbol = '▼' select_button_dist_to_move = self.selected_option_button.rect.height option_button_dist_to_move = self.base_position_rect.height close_button_x = self.base_position_rect.x + self.base_position_rect.width - self.close_button_width self.close_button = UIButton(pygame.Rect((close_button_x, self.base_position_rect.y), (self.close_button_width, self.base_position_rect.height)), expand_button_symbol, self.ui_manager, self.ui_container, starting_height=2, parent_element=self.drop_down_menu_ui, object_id='#expand_button') option_y_pos += select_button_dist_to_move for option in self.options_list: new_button = UIButton(pygame.Rect((self.base_position_rect.x, option_y_pos), (self.base_position_rect.width - self.close_button_width, self.base_position_rect.height)), option, self.ui_manager, self.ui_container, starting_height=3, # height allows options to overlap other UI elements parent_element=self.drop_down_menu_ui, object_id='#option') option_y_pos += option_button_dist_to_move self.menu_buttons.append(new_button) self.rebuild() def finish(self): """ cleans everything up upon exiting the expanded menu state. """ for button in self.menu_buttons: button.kill() self.menu_buttons.clear() self.selected_option_button.kill() self.close_button.kill() self.drop_down_menu_ui.rect.y += self.rect_height_offset self.drop_down_menu_ui.relative_rect.y += self.rect_height_offset def update(self): if self.close_button is not None and self.close_button.check_pressed(): self.should_transition = True for button in self.menu_buttons: if button.check_pressed(): self.drop_down_menu_ui.selected_option = button.text self.should_transition = True drop_down_changed_event = pygame.event.Event(pygame.USEREVENT, {'user_type': 'ui_drop_down_menu_changed', 'text': button.text, 'ui_element': self.drop_down_menu_ui, 'ui_object_id': self.object_ids[-1]}) pygame.event.post(drop_down_changed_event)
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. """ 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[str, None] = None, anchors: Dict[str, str] = None ): new_element_ids, new_object_ids = self.create_valid_ids(container=container, parent_element=parent_element, object_id=object_id, element_id='horizontal_slider') super().__init__(relative_rect, manager, container, layer_thickness=2, starting_height=1, element_ids=new_element_ids, object_ids=new_object_ids, anchors=anchors) 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 self.value_range = value_range value_range_length = self.value_range[1] - self.value_range[0] self.current_value = int(self.value_range[0] + (self.current_percentage * value_range_length)) 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.border_width = None self.shadow_width = None self.drawable_shape = None self.shape_type = 'rectangle' self.shape_corner_radius = None self.background_rect = None 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.arrow_buttons_enabled = True self.button_container = None 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='#sliding_button', anchors={'left': 'left', 'right': 'left', 'top': 'top', 'bottom': 'bottom'} ) 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, 'border_width': self.border_width, 'shadow_width': self.shadow_width, 'shape_corner_radius': self.shape_corner_radius} if self.shape_type == 'rectangle': self.drawable_shape = RectDrawableShape(self.rect, theming_parameters, ['normal'], self.ui_manager) elif self.shape_type == 'rounded_rectangle': self.drawable_shape = RoundedRectangleShape(self.rect, theming_parameters, ['normal'], self.ui_manager) self.set_image(self.drawable_shape.get_surface('normal')) 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') 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.arrow_buttons_enabled: 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='#left_button', anchors={'left': 'left', 'right': 'left', 'top': 'top', 'bottom': 'bottom'} ) 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='#right_button', anchors={'left': 'right', 'right': 'right', 'top': 'top', 'bottom': 'bottom'}) 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) 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(): return moved_this_frame = False if self.left_button is not None and (self.left_button.held and self.scroll_position > self.left_limit_position): 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 elif self.right_button is not None and (self.right_button.held and self.scroll_position < self.right_limit_position): 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 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 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 event_data = {'user_type': UI_HORIZONTAL_SLIDER_MOVED, 'value': self.current_value, 'ui_element': self, 'ui_object_id': self.most_specific_combined_id} slider_moved_event = pygame.event.Event(pygame.USEREVENT, event_data) pygame.event.post(slider_moved_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]): """ 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. """ if min(self.value_range[0], self.value_range[1]) <= value <= max(self.value_range[0], self.value_range[1]): self.current_value = float(value) value_range_size = (self.value_range[1] - self.value_range[0]) if value_range_size != 0: self.current_percentage = (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 else: warnings.warn('value not in range', UserWarning) def rebuild_from_changed_theme_data(self): """ Called by the UIManager to check the theming data and rebuild whatever needs rebuilding for this element when the theme data has changed. """ has_any_changed = False shape_type = 'rectangle' shape_type_string = self.ui_theme.get_misc_data(self.object_ids, self.element_ids, 'shape') if shape_type_string is not None and shape_type_string in ['rectangle', 'rounded_rectangle']: shape_type = shape_type_string if shape_type != self.shape_type: self.shape_type = shape_type has_any_changed = True if self._check_shape_theming_changed(defaults={'border_width': 1, 'shadow_width': 2, 'shape_corner_radius': 2}): has_any_changed = True background_colour = self.ui_theme.get_colour_or_gradient(self.object_ids, self.element_ids, 'dark_bg') if background_colour != self.background_colour: self.background_colour = background_colour has_any_changed = True border_colour = self.ui_theme.get_colour_or_gradient(self.object_ids, self.element_ids, 'normal_border') if border_colour != self.border_colour: self.border_colour = border_colour has_any_changed = True buttons_enable_param = self.ui_theme.get_misc_data(self.object_ids, self.element_ids, 'enable_arrow_buttons') if buttons_enable_param is not None: try: buttons_enable = bool(int(buttons_enable_param)) except ValueError: buttons_enable = True if buttons_enable != self.arrow_buttons_enabled: self.arrow_buttons_enabled = buttons_enable has_any_changed = True sliding_button_width = self.default_button_width sliding_button_width_string = self.ui_theme.get_misc_data(self.object_ids, self.element_ids, 'sliding_button_width') if sliding_button_width_string is not None: try: sliding_button_width = int(sliding_button_width_string) except ValueError: sliding_button_width = self.default_button_width if sliding_button_width != self.sliding_button_width: self.sliding_button_width = sliding_button_width 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))
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. """ def __init__(self, rect: pygame.Rect, manager: IUIManagerInterface, window_display_title: str = "", element_id: Union[str, None] = None, object_id: Union[str, None] = None, resizable: bool = False): if element_id is None: element_id = 'window' new_element_ids, new_object_ids = self.create_valid_ids(container=None, parent_element=None, object_id=object_id, element_id=element_id) self.window_display_title = window_display_title self._window_root_container = 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, object_ids=new_object_ids, element_ids=new_element_ids) 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 self.grabbed_window = False self.starting_grab_difference = (0, 0) self.background_colour = None self.border_colour = None self.shape_type = 'rectangle' self.enable_title_bar = True self.enable_close_button = True self.title_bar_height = 28 self.title_bar_button_width = self.title_bar_height # UI elements self.window_element_container = None self.title_bar = None self.close_window_button = 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 = (int(event.pos[0] * self.ui_manager.mouse_pos_scale_factor[0]), int(event.pos[1] * self.ui_manager.mouse_pos_scale_factor[1])) if event.button == pygame.BUTTON_LEFT and (self.edge_hovering[0] or self.edge_hovering[1] or self.edge_hovering[2] or self.edge_hovering[3]): 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 = (int(event.pos[0] * self.ui_manager.mouse_pos_scale_factor[0]), int(event.pos[1] * self.ui_manager.mouse_pos_scale_factor[1])) if self.hover_point(scaled_mouse_pos[0], scaled_mouse_pos[1]) or (self.edge_hovering[0] or self.edge_hovering[1] or self.edge_hovering[2] or self.edge_hovering[3]): if 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 self.rect.height >= 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 self.rect.width >= 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) -> UIContainer: """ 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") 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_type == 'rectangle': self.drawable_shape = RectDrawableShape(self.rect, theming_parameters, ['normal'], self.ui_manager) elif self.shape_type == 'rounded_rectangle': self.drawable_shape = RoundedRectangleShape(self.rect, theming_parameters, ['normal'], self.ui_manager) self.set_image(self.drawable_shape.get_surface('normal')) 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.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_button_width, self.title_bar_height)) else: title_bar_width = (self._window_root_container.relative_rect.width - self.title_bar_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_button_width, 0) self.close_window_button.set_dimensions((self.title_bar_button_width, self.title_bar_height)) self.close_window_button.set_relative_position(close_button_pos) else: close_rect = pygame.Rect((-self.title_bar_button_width, 0), (self.title_bar_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. """ has_any_changed = False shape_type = 'rectangle' shape_type_string = self.ui_theme.get_misc_data(self.object_ids, self.element_ids, 'shape') if shape_type_string is not None and shape_type_string in ['rectangle', 'rounded_rectangle']: shape_type = shape_type_string if shape_type != self.shape_type: self.shape_type = shape_type has_any_changed = True if self._check_shape_theming_changed(defaults={'border_width': 1, 'shadow_width': 15, 'shape_corner_radius': 2}): has_any_changed = True background_colour = self.ui_theme.get_colour_or_gradient(self.object_ids, self.element_ids, 'dark_bg') if background_colour != self.background_colour: self.background_colour = background_colour has_any_changed = True border_colour = self.ui_theme.get_colour_or_gradient(self.object_ids, self.element_ids, 'normal_border') if border_colour != self.border_colour: self.border_colour = border_colour has_any_changed = True enable_title_bar_param = self.ui_theme.get_misc_data(self.object_ids, self.element_ids, 'enable_title_bar') if enable_title_bar_param is not None: try: enable_title_bar = bool(int(enable_title_bar_param)) except ValueError: enable_title_bar = True if enable_title_bar != self.enable_title_bar: self.enable_title_bar = enable_title_bar has_any_changed = True if self.enable_title_bar: enable_close_button_param = self.ui_theme.get_misc_data(self.object_ids, self.element_ids, 'enable_close_button') if enable_close_button_param is not None: try: enable_close_button = bool(int(enable_close_button_param)) except ValueError: enable_close_button = True if enable_close_button != self.enable_close_button: self.enable_close_button = enable_close_button has_any_changed = True title_bar_height_string = self.ui_theme.get_misc_data(self.object_ids, self.element_ids, 'title_bar_height') if title_bar_height_string is not None: try: title_bar_height = int(title_bar_height_string) except ValueError: title_bar_height = 28 if title_bar_height != self.title_bar_height: self.title_bar_height = title_bar_height self.title_bar_button_width = title_bar_height has_any_changed = True else: self.title_bar_height = 0 if has_any_changed: self.rebuild()
class SelectLevelMenu(BaseAppState): def __init__(self, ui_manager: pygame_gui.UIManager, state_manager): super().__init__('select_level', 'game', state_manager) self.ui_manager = ui_manager self.all_level_paths = [] self.selected_level_path = None self.background_image = None self.title_label = None self.play_game_button = None self.edit_map_button = None self.level_button_group = [] self.level_group_y_start = 100 self.reload_levels() def start(self): self.level_group_y_start = 100 self.selected_level_path = self.all_level_paths[0].path self.background_image = pygame.image.load( "images/menu_background.png").convert() self.title_label = UILabel(pygame.Rect((400, 25), (229, 60)), "Select Level", self.ui_manager, object_id="#game_sub_title") self.play_game_button = UIButton( pygame.Rect((437, 515), (150, 35)), "Start Level", self.ui_manager, tool_tip_text="<b>Click to start level.</b>") self.edit_map_button = UIButton( pygame.Rect((437, 555), (150, 35)), "Edit Level", self.ui_manager, tool_tip_text="<b>Click to enter the level editor.</b>") for level_data in self.all_level_paths: self.level_button_group.append( UIButton(pygame.Rect((437, self.level_group_y_start), (150, 20)), level_data.display_name, self.ui_manager, tool_tip_text="<b>Select this level.</b>", object_id="#choose_level_button")) self.level_group_y_start += 25 if len(self.level_button_group) > 0: self.ui_manager.set_focus_element(self.level_button_group[0]) def end(self): self.title_label.kill() self.play_game_button.kill() self.edit_map_button.kill() for button in self.level_button_group: button.kill() self.level_button_group.clear() def reload_levels(self): self.all_level_paths[:] = [] for level_file in os.listdir("data/levels/"): full_file_name = "data/levels/" + level_file level_data = LevelUIData(full_file_name) self.all_level_paths.append(level_data) def run(self, surface, time_delta): for event in pygame.event.get(): if event.type == QUIT: self.set_target_state_name('quit') self.trigger_transition() if event.type == pygame.KEYDOWN: if event.key == pygame.K_ESCAPE: self.trigger_transition() self.ui_manager.process_events(event) if event.type == pygame.USEREVENT: if event.user_type == "ui_button_pressed": if event.ui_element == self.play_game_button: self.set_target_state_name('game') self.outgoing_transition_data[ 'selected_level_path'] = self.selected_level_path self.trigger_transition() if event.ui_element == self.edit_map_button: self.set_target_state_name('editor') self.outgoing_transition_data[ 'selected_level_path'] = self.selected_level_path self.trigger_transition() if event.ui_object_id == "#choose_level_button": self.ui_manager.set_focus_element(event.ui_element) for level_data in self.all_level_paths: if level_data.display_name == event.ui_element.text: self.selected_level_path = level_data.path self.ui_manager.update(time_delta) surface.blit(self.background_image, (0, 0)) # draw the background self.ui_manager.draw_ui(surface)
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 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. """ def __init__(self, relative_rect: pygame.Rect, start_value: Union[float, int], value_range: Tuple[Union[float, int], Union[float, int]], manager: ui_manager.UIManager, container: ui_container.UIContainer = None, parent_element: UIElement = None, object_id: Union[str, None] = None): new_element_ids, new_object_ids = self.create_valid_ids(parent_element=parent_element, object_id=object_id, element_id='horizontal_slider') super().__init__(relative_rect, manager, container, object_ids=new_object_ids, element_ids=new_element_ids, starting_height=1, layer_thickness=1) self.button_width = 20 self.current_percentage = 0.5 self.value_range = value_range self.grabbed_slider = False self.starting_grab_x_difference = 0 self.has_moved_recently = False value_range_length = self.value_range[1] - self.value_range[0] self.current_value = int(self.value_range[0] + (self.current_percentage * value_range_length)) self.left_limit_position = 0.0 self.border_width = None self.shadow_width = None self.border_colour = None self.background_colour = None self.background_rect = None self.sliding_button = None 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.drawable_shape = None self.shape_type = 'rectangle' self.shape_corner_radius = None self.rebuild_from_changed_theme_data() # Things below here depend on theme data so need to be updated on a rebuild self.left_button = UIButton(pygame.Rect(self.background_rect.topleft, (self.button_width, self.background_rect.height)), '◀', self.ui_manager, self.ui_container, starting_height=2, parent_element=self, object_id='#left_button') self.right_button = UIButton(pygame.Rect((self.background_rect.x + self.background_rect.width - self.button_width, self.background_rect.y), (self.button_width, self.background_rect.height)), '▶', self.ui_manager, self.ui_container, starting_height=2, parent_element=self, object_id='#right_button') sliding_x_pos = int(self.background_rect.x + self.background_rect.width/2 - self.button_width/2) self.sliding_button = UIButton(pygame.Rect((sliding_x_pos, self.background_rect.y), (self.button_width, self.background_rect.height)), '', self.ui_manager, self.ui_container, starting_height=2, parent_element=self, object_id='#sliding_button') 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. """ relative_background_rect = pygame.Rect((self.border_width + self.shadow_width, self.border_width + self.shadow_width), (self.rect.width - (2 * self.shadow_width) - (2 * self.border_width), self.rect.height - (2 * self.shadow_width) - (2 * self.border_width))) self.background_rect = pygame.Rect((relative_background_rect.x + self.relative_rect.x, relative_background_rect.y + self.relative_rect.y), relative_background_rect.size) theming_parameters = {'normal_bg': self.background_colour, 'normal_border': self.border_colour, 'border_width': self.border_width, 'shadow_width': self.shadow_width, 'shape_corner_radius': self.shape_corner_radius} if self.shape_type == 'rectangle': self.drawable_shape = RectDrawableShape(self.rect, theming_parameters, ['normal'], self.ui_manager) elif self.shape_type == 'rounded_rectangle': self.drawable_shape = RoundedRectangleShape(self.rect, theming_parameters, ['normal'], self.ui_manager) self.image = self.drawable_shape.get_surface('normal') # Things below here depend on theme data so need to be updated on a rebuild self.scrollable_width = self.background_rect.width - (3 * self.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.x + self.background_rect.width / 2 - self.button_width / 2) self.sliding_button.set_relative_position((sliding_x_pos, self.background_rect.y)) self.sliding_button.set_dimensions((self.button_width, self.background_rect.height)) self.sliding_button.set_hold_range((self.background_rect.width, 100)) self.set_current_value(self.current_value) if self.left_button is not None: self.left_button.set_relative_position(self.background_rect.topleft), self.left_button.set_dimensions((self.button_width, self.background_rect.height)) if self.right_button is not None: self.right_button.set_relative_position((self.background_rect.x + self.background_rect.width - self.button_width, self.background_rect.y)), self.right_button.set_dimensions((self.button_width, self.background_rect.height)) def kill(self): """ Overrides the normal sprite kill() method to also kill the button elements that help make up the slider. """ self.left_button.kill() self.right_button.kill() self.sliding_button.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. """ if self.alive(): moved_this_frame = False if self.left_button.held and self.scroll_position > self.left_limit_position: self.scroll_position -= (250.0 * time_delta) self.scroll_position = max(self.scroll_position, self.left_limit_position) x_pos = self.scroll_position + self.rect.x + self.shadow_width + self.border_width + self.button_width y_pos = self.rect.y + self.shadow_width + self.border_width self.sliding_button.set_position(pygame.math.Vector2(x_pos, y_pos)) moved_this_frame = True elif self.right_button.held and self.scroll_position < self.right_limit_position: self.scroll_position += (250.0 * time_delta) self.scroll_position = min(self.scroll_position, self.right_limit_position) x_pos = self.scroll_position + self.rect.x + self.shadow_width + self.border_width + self.button_width y_pos = self.rect.y + self.shadow_width + self.border_width self.sliding_button.set_position(pygame.math.Vector2(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.scroll_position + self.rect.x + self.shadow_width + self.border_width + self.button_width) self.starting_grab_x_difference = mouse_x - real_scroll_pos real_scroll_pos = (self.scroll_position + self.rect.x + self.shadow_width + self.border_width + self.button_width) 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.rect.x + self.shadow_width + self.border_width + self.button_width y_pos = self.rect.y + self.shadow_width + self.border_width self.sliding_button.set_position(pygame.math.Vector2(x_pos, y_pos)) moved_this_frame = True elif not self.sliding_button.held: self.grabbed_slider = False if moved_this_frame: self.current_value = self.value_range[0] + ( (self.scroll_position / self.scrollable_width) * (self.value_range[1] - self.value_range[0])) if not self.has_moved_recently: self.has_moved_recently = True 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 return self.current_value def set_current_value(self, value: Union[float, int]): """ 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. """ if min(self.value_range[0], self.value_range[1]) <= value <= max(self.value_range[0], self.value_range[1]): self.current_value = float(value) value_range_size = (self.value_range[1] - self.value_range[0]) if value_range_size != 0: percentage = (self.current_value - self.value_range[0])/value_range_size self.scroll_position = self.scrollable_width * percentage x_pos = self.scroll_position + self.rect.x + self.shadow_width + self.border_width + self.button_width y_pos = self.rect.y + self.shadow_width + self.border_width self.sliding_button.set_position(pygame.math.Vector2(x_pos, y_pos)) self.has_moved_recently = True else: warnings.warn('value not in range', UserWarning) def rebuild_from_changed_theme_data(self): """ Called by the UIManager to check the theming data and rebuild whatever needs rebuilding for this element when the theme data has changed. """ has_any_changed = False shape_type = 'rectangle' shape_type_string = self.ui_theme.get_misc_data(self.object_ids, self.element_ids, 'shape') if shape_type_string is not None: if shape_type_string in ['rectangle', 'rounded_rectangle']: shape_type = shape_type_string if shape_type != self.shape_type: self.shape_type = shape_type has_any_changed = True corner_radius = 2 shape_corner_radius_string = self.ui_theme.get_misc_data(self.object_ids, self.element_ids, 'shape_corner_radius') if shape_corner_radius_string is not None: try: corner_radius = int(shape_corner_radius_string) except ValueError: corner_radius = 2 if corner_radius != self.shape_corner_radius: self.shape_corner_radius = corner_radius has_any_changed = True border_width = 1 border_width_string = self.ui_theme.get_misc_data(self.object_ids, self.element_ids, 'border_width') if border_width_string is not None: try: border_width = int(border_width_string) except ValueError: border_width = 1 if border_width != self.border_width: self.border_width = border_width has_any_changed = True shadow_width = 2 shadow_width_string = self.ui_theme.get_misc_data(self.object_ids, self.element_ids, 'shadow_width') if shadow_width_string is not None: try: shadow_width = int(shadow_width_string) except ValueError: shadow_width = 2 if shadow_width != self.shadow_width: self.shadow_width = shadow_width has_any_changed = True background_colour = self.ui_theme.get_colour_or_gradient(self.object_ids, self.element_ids, 'dark_bg') if background_colour != self.background_colour: self.background_colour = background_colour has_any_changed = True border_colour = self.ui_theme.get_colour_or_gradient(self.object_ids, self.element_ids, 'normal_border') if border_colour != self.border_colour: self.border_colour = border_colour has_any_changed = True if has_any_changed: self.rebuild()
class UIHorizontalSlider(UIElement): """ A horizontal slider is intended to help users adjust values within a range, for example a volume control. :param relative_rect: A rectangle describing the position and dimensions of the element. :param start_value: The value to start the slider at. :param value_range: The full range of values. :param manager: The UIManager that manages this element. :param container: The container that this element is within. If set to None will be the root window's container. :param parent_element: The element this element 'belongs to' in the theming hierarchy. :param object_id: A custom defined ID for fine tuning of theming. :param anchors: A dictionary describing what this element's relative_rect is relative to. :param visible: Whether the element is visible by default. Warning - container visibility may override this. :param click_increment: the amount to increment by when clicking one of the arrow buttons. """ def __init__(self, relative_rect: pygame.Rect, start_value: Union[float, int], value_range: Tuple[Union[float, int], Union[float, int]], manager: IUIManagerInterface, container: Union[IContainerLikeInterface, None] = None, parent_element: UIElement = None, object_id: Union[ObjectID, str, None] = None, anchors: Dict[str, str] = None, visible: int = 1, click_increment: Union[float, int] = 1): super().__init__(relative_rect, manager, container, layer_thickness=2, starting_height=1, anchors=anchors, visible=visible) self._create_valid_ids(container=container, parent_element=parent_element, object_id=object_id, element_id='horizontal_slider') self.default_button_width = 20 self.arrow_button_width = self.default_button_width self.sliding_button_width = self.default_button_width self.current_percentage = 0.5 self.left_limit_position = 0.0 self.starting_grab_x_difference = 0 if (isinstance(start_value, int) and isinstance(value_range[0], int) and isinstance(value_range[1], int)): self.use_integers_for_value = True else: self.use_integers_for_value = False self.value_range = value_range value_range_length = self.value_range[1] - self.value_range[0] self.current_value = self.value_range[0] + (self.current_percentage * value_range_length) if self.use_integers_for_value: self.current_value = int(self.current_value) self.grabbed_slider = False self.has_moved_recently = False self.has_been_moved_by_user_recently = False self.background_colour = None self.border_colour = None self.disabled_border_colour = None self.disabled_background_colour = None self.border_width = None self.shadow_width = None self.drawable_shape = None self.shape = 'rectangle' self.shape_corner_radius = None self.background_rect = None # type: Optional[pygame.Rect] self.scrollable_width = None self.right_limit_position = None self.scroll_position = None self.left_button = None self.right_button = None self.sliding_button = None self.enable_arrow_buttons = True self.button_container = None self.button_held_repeat_time = 0.2 self.button_held_repeat_acc = 0.0 self.increment = click_increment self.rebuild_from_changed_theme_data() sliding_x_pos = int(self.background_rect.width / 2 - self.sliding_button_width / 2) self.sliding_button = UIButton(pygame.Rect( (sliding_x_pos, 0), (self.sliding_button_width, self.background_rect.height)), '', self.ui_manager, container=self.button_container, starting_height=1, parent_element=self, object_id=ObjectID( object_id='#sliding_button', class_id='None'), anchors={ 'left': 'left', 'right': 'left', 'top': 'top', 'bottom': 'bottom' }, visible=self.visible) self.sliding_button.set_hold_range((self.background_rect.width, 100)) self.set_current_value(start_value) def rebuild(self): """ Rebuild anything that might need rebuilding. """ border_and_shadow = self.border_width + self.shadow_width self.background_rect = pygame.Rect( (border_and_shadow + self.relative_rect.x, border_and_shadow + self.relative_rect.y), (self.relative_rect.width - (2 * border_and_shadow), self.relative_rect.height - (2 * border_and_shadow))) theming_parameters = { 'normal_bg': self.background_colour, 'normal_border': self.border_colour, 'disabled_bg': self.disabled_background_colour, 'disabled_border': self.disabled_border_colour, 'border_width': self.border_width, 'shadow_width': self.shadow_width, 'shape_corner_radius': self.shape_corner_radius } if self.shape == 'rectangle': self.drawable_shape = RectDrawableShape(self.rect, theming_parameters, ['normal', 'disabled'], self.ui_manager) elif self.shape == 'rounded_rectangle': self.drawable_shape = RoundedRectangleShape( self.rect, theming_parameters, ['normal', 'disabled'], self.ui_manager) self.set_image(self.drawable_shape.get_fresh_surface()) if self.button_container is None: self.button_container = UIContainer( self.background_rect, manager=self.ui_manager, container=self.ui_container, anchors=self.anchors, object_id='#horiz_scrollbar_buttons_container', visible=self.visible) else: self.button_container.set_dimensions(self.background_rect.size) self.button_container.set_relative_position( self.background_rect.topleft) # Things below here depend on theme data so need to be updated on a rebuild if self.enable_arrow_buttons: self.arrow_button_width = self.default_button_width if self.left_button is None: self.left_button = UIButton(pygame.Rect( (0, 0), (self.arrow_button_width, self.background_rect.height)), '◀', self.ui_manager, container=self.button_container, starting_height=1, parent_element=self, object_id=ObjectID( "#left_button", "@arrow_button"), anchors={ 'left': 'left', 'right': 'left', 'top': 'top', 'bottom': 'bottom' }, visible=self.visible) if self.right_button is None: self.right_button = UIButton(pygame.Rect( (-self.arrow_button_width, 0), (self.arrow_button_width, self.background_rect.height)), '▶', self.ui_manager, container=self.button_container, starting_height=1, parent_element=self, object_id=ObjectID( "#right_button", "@arrow_button"), anchors={ 'left': 'right', 'right': 'right', 'top': 'top', 'bottom': 'bottom' }, visible=self.visible) else: self.arrow_button_width = 0 if self.left_button is not None: self.left_button.kill() self.left_button = None if self.right_button is not None: self.right_button.kill() self.right_button = None self.scrollable_width = (self.background_rect.width - self.sliding_button_width - (2 * self.arrow_button_width)) self.right_limit_position = self.scrollable_width self.scroll_position = self.scrollable_width / 2 if self.sliding_button is not None: sliding_x_pos = int((self.background_rect.width / 2) - (self.sliding_button_width / 2)) self.sliding_button.set_relative_position((sliding_x_pos, 0)) self.sliding_button.set_dimensions( (self.sliding_button_width, self.background_rect.height)) self.sliding_button.set_hold_range( (self.background_rect.width, 100)) self.set_current_value(self.current_value, False) def kill(self): """ Overrides the normal sprite kill() method to also kill the button elements that help make up the slider. """ self.button_container.kill() super().kill() def update(self, time_delta: float): """ Takes care of actually moving the slider based on interactions reported by the buttons or based on movement of the mouse if we are gripping the slider itself. :param time_delta: the time in seconds between calls to update. """ super().update(time_delta) if not (self.alive() and self.is_enabled): return moved_this_frame = False moved_this_frame = self._update_arrow_buttons(moved_this_frame, time_delta) mouse_x, mouse_y = self.ui_manager.get_mouse_position() if self.sliding_button.held and self.sliding_button.in_hold_range( (mouse_x, mouse_y)): if not self.grabbed_slider: self.grabbed_slider = True real_scroll_pos = self.sliding_button.rect.left self.starting_grab_x_difference = mouse_x - real_scroll_pos real_scroll_pos = self.sliding_button.rect.left current_grab_difference = mouse_x - real_scroll_pos adjustment_required = current_grab_difference - self.starting_grab_x_difference self.scroll_position = self.scroll_position + adjustment_required self.scroll_position = min( max(self.scroll_position, self.left_limit_position), self.right_limit_position) x_pos = (self.scroll_position + self.arrow_button_width) y_pos = 0 self.sliding_button.set_relative_position((x_pos, y_pos)) moved_this_frame = True elif not self.sliding_button.held: self.grabbed_slider = False if moved_this_frame: self.current_percentage = self.scroll_position / self.scrollable_width self.current_value = self.value_range[0] + ( self.current_percentage * (self.value_range[1] - self.value_range[0])) if self.use_integers_for_value: self.current_value = int(self.current_value) if not self.has_moved_recently: self.has_moved_recently = True if not self.has_been_moved_by_user_recently: self.has_been_moved_by_user_recently = True # old event - to be removed in 0.8.0 event_data = { 'user_type': OldType(UI_HORIZONTAL_SLIDER_MOVED), 'value': self.current_value, 'ui_element': self, 'ui_object_id': self.most_specific_combined_id } pygame.event.post(pygame.event.Event(pygame.USEREVENT, event_data)) # new event event_data = { 'value': self.current_value, 'ui_element': self, 'ui_object_id': self.most_specific_combined_id } pygame.event.post( pygame.event.Event(UI_HORIZONTAL_SLIDER_MOVED, event_data)) def _update_arrow_buttons(self, moved_this_frame, time_delta): if self.left_button is not None and ( self.left_button.held and self.scroll_position > self.left_limit_position): if self.button_held_repeat_acc > self.button_held_repeat_time: self.scroll_position -= (250.0 * time_delta) self.scroll_position = max(self.scroll_position, self.left_limit_position) x_pos = (self.scroll_position + self.arrow_button_width) y_pos = 0 self.sliding_button.set_relative_position((x_pos, y_pos)) moved_this_frame = True else: self.button_held_repeat_acc += time_delta elif self.right_button is not None and ( self.right_button.held and self.scroll_position < self.right_limit_position): if self.button_held_repeat_acc > self.button_held_repeat_time: self.scroll_position += (250.0 * time_delta) self.scroll_position = min(self.scroll_position, self.right_limit_position) x_pos = (self.scroll_position + self.arrow_button_width) y_pos = 0 self.sliding_button.set_relative_position((x_pos, y_pos)) moved_this_frame = True else: self.button_held_repeat_acc += time_delta else: self.button_held_repeat_acc = 0.0 return moved_this_frame def process_event(self, event: pygame.event.Event) -> bool: processed_event = False if event.type == UI_BUTTON_PRESSED: if (event.ui_element in [self.left_button, self.right_button] and self.button_held_repeat_acc < self.button_held_repeat_time and (self.value_range[0] <= self.get_current_value() <= self.value_range[1])): old_value = self.get_current_value() new_value = (old_value - self.increment if event.ui_element == self.left_button else old_value + self.increment) self.set_current_value(new_value, False) processed_event = True event_data = { 'value': self.current_value, 'ui_element': self, 'ui_object_id': self.most_specific_combined_id } pygame.event.post( pygame.event.Event(UI_HORIZONTAL_SLIDER_MOVED, event_data)) return processed_event def get_current_value(self) -> Union[float, int]: """ Gets the current value the slider is set to. :return: The current value recorded by the slider. """ self.has_moved_recently = False self.has_been_moved_by_user_recently = False return self.current_value def set_current_value(self, value: Union[float, int], warn: bool = True): """ Sets the value of the slider, which will move the position of the slider to match. Will issue a warning if the value set is not in the value range. :param value: The value to set. :param warn: set to false to suppress the default warning, instead the value will be clamped. """ if self.use_integers_for_value: value = int(value) min_value = min(self.value_range[0], self.value_range[1]) max_value = max(self.value_range[0], self.value_range[1]) if value < min_value or value > max_value: if warn: warnings.warn('value not in range', UserWarning) return else: self.current_value = max(min(value, max_value), min_value) else: self.current_value = value value_range_size = (self.value_range[1] - self.value_range[0]) if value_range_size != 0: self.current_percentage = (float(self.current_value) - self.value_range[0]) / value_range_size self.scroll_position = self.scrollable_width * self.current_percentage x_pos = (self.scroll_position + self.arrow_button_width) y_pos = 0 self.sliding_button.set_relative_position((x_pos, y_pos)) self.has_moved_recently = True def rebuild_from_changed_theme_data(self): """ Called by the UIManager to check the theming data and rebuild whatever needs rebuilding for this element when the theme data has changed. """ super().rebuild_from_changed_theme_data() has_any_changed = False if self._check_misc_theme_data_changed( attribute_name='shape', default_value='rectangle', casting_func=str, allowed_values=['rectangle', 'rounded_rectangle']): has_any_changed = True if self._check_shape_theming_changed(defaults={ 'border_width': 1, 'shadow_width': 2, 'shape_corner_radius': 2 }): has_any_changed = True background_colour = self.ui_theme.get_colour_or_gradient( 'dark_bg', self.combined_element_ids) if background_colour != self.background_colour: self.background_colour = background_colour has_any_changed = True border_colour = self.ui_theme.get_colour_or_gradient( 'normal_border', self.combined_element_ids) if border_colour != self.border_colour: self.border_colour = border_colour has_any_changed = True disabled_background_colour = self.ui_theme.get_colour_or_gradient( 'disabled_dark_bg', self.combined_element_ids) if disabled_background_colour != self.disabled_background_colour: self.disabled_background_colour = disabled_background_colour has_any_changed = True disabled_border_colour = self.ui_theme.get_colour_or_gradient( 'disabled_border', self.combined_element_ids) if disabled_border_colour != self.disabled_border_colour: self.disabled_border_colour = disabled_border_colour has_any_changed = True def parse_to_bool(str_data: str): return bool(int(str_data)) if self._check_misc_theme_data_changed( attribute_name='enable_arrow_buttons', default_value=True, casting_func=parse_to_bool): has_any_changed = True if self._check_misc_theme_data_changed( attribute_name='sliding_button_width', default_value=self.default_button_width, casting_func=int): has_any_changed = True if has_any_changed: self.rebuild() def set_position(self, position: Union[pygame.math.Vector2, Tuple[int, int], Tuple[float, float]]): """ Sets the absolute screen position of this slider, updating all subordinate button elements at the same time. :param position: The absolute screen position to set. """ super().set_position(position) border_and_shadow = self.border_width + self.shadow_width self.background_rect.x = border_and_shadow + self.relative_rect.x self.background_rect.y = border_and_shadow + self.relative_rect.y self.button_container.set_relative_position( self.background_rect.topleft) def set_relative_position(self, position: Union[pygame.math.Vector2, Tuple[int, int], Tuple[float, float]]): """ Sets the relative screen position of this slider, updating all subordinate button elements at the same time. :param position: The relative screen position to set. """ super().set_relative_position(position) border_and_shadow = self.border_width + self.shadow_width self.background_rect.x = border_and_shadow + self.relative_rect.x self.background_rect.y = border_and_shadow + self.relative_rect.y self.button_container.set_relative_position( self.background_rect.topleft) def set_dimensions(self, dimensions: Union[pygame.math.Vector2, Tuple[int, int], Tuple[float, float]]): """ Method to directly set the dimensions of an element. :param dimensions: The new dimensions to set. """ super().set_dimensions(dimensions) border_and_shadow = self.border_width + self.shadow_width self.background_rect.width = self.relative_rect.width - ( 2 * border_and_shadow) self.background_rect.height = self.relative_rect.height - ( 2 * border_and_shadow) self.button_container.set_dimensions(self.background_rect.size) # sort out sliding button parameters self.scrollable_width = (self.background_rect.width - self.sliding_button_width - (2 * self.arrow_button_width)) self.right_limit_position = self.scrollable_width self.scroll_position = self.scrollable_width * self.current_percentage slider_x_pos = self.scroll_position + self.arrow_button_width slider_y_pos = 0 self.sliding_button.set_dimensions( (self.sliding_button_width, self.background_rect.height)) self.sliding_button.set_relative_position((slider_x_pos, slider_y_pos)) def disable(self): """ Disable the slider. It should not be interactive and will use the disabled theme colours. """ if self.is_enabled: self.is_enabled = False self.sliding_button.disable() if self.left_button: self.left_button.disable() if self.right_button: self.right_button.disable() self.drawable_shape.set_active_state('disabled') def enable(self): """ Enable the slider. It should become interactive and will use the normal theme colours. """ if not self.is_enabled: self.is_enabled = True self.sliding_button.enable() if self.left_button: self.left_button.enable() if self.right_button: self.right_button.enable() self.drawable_shape.set_active_state('normal') def show(self): """ In addition to the base UIElement.show() - show the sliding button and show the button_container which will propagate and show the left and right buttons. """ super().show() self.sliding_button.show() if self.button_container is not None: self.button_container.show() def hide(self): """ In addition to the base UIElement.hide() - hide the sliding button and hide the button_container which will propagate and hide the left and right buttons. """ super().hide() self.sliding_button.hide() if self.button_container is not None: self.button_container.hide()
class UIClosedDropDownState: """ The closed state of the drop down just displays the currently chosen option and a button that will switch the menu to the expanded state. """ def __init__(self, drop_down_menu_ui, selected_option, base_position_rect, open_button_width, manager, container, element_ids, object_ids): self.drop_down_menu_ui = drop_down_menu_ui self.selected_option_button = None self.open_button = None self.selected_option = selected_option self.base_position_rect = base_position_rect self.ui_manager = manager self.ui_container = container self.element_ids = element_ids self.object_ids = object_ids self.open_button_width = open_button_width self.should_transition = False self.target_state = 'expanded' def start(self): """ Called each time we enter the closed state. It creates the necessary elements, the selected option and the open button. """ # First handle the background if self.drop_down_menu_ui.shadow_width > 0: self.drop_down_menu_ui.image = self.ui_manager.get_shadow( self.drop_down_menu_ui.rect.size) else: self.drop_down_menu_ui.image = pygame.Surface( self.drop_down_menu_ui.rect.size, flags=pygame.SRCALPHA) border_rect = pygame.Rect((self.drop_down_menu_ui.shadow_width, self.drop_down_menu_ui.shadow_width), (self.drop_down_menu_ui.rect.width - (2 * self.drop_down_menu_ui.shadow_width), self.drop_down_menu_ui.rect.height - (2 * self.drop_down_menu_ui.shadow_width))) if self.drop_down_menu_ui.border_width > 0: self.drop_down_menu_ui.image.fill( self.drop_down_menu_ui.border_colour, border_rect) relative_background_rect = pygame.Rect( (self.drop_down_menu_ui.border_width + self.drop_down_menu_ui.shadow_width, self.drop_down_menu_ui.border_width + self.drop_down_menu_ui.shadow_width), (border_rect.width - (2 * self.drop_down_menu_ui.border_width), border_rect.height - (2 * self.drop_down_menu_ui.border_width))) self.drop_down_menu_ui.image.fill( self.drop_down_menu_ui.background_colour, relative_background_rect) self.should_transition = False self.selected_option_button = UIButton( pygame.Rect( (self.base_position_rect.x, self.base_position_rect.y), (self.base_position_rect.width - self.open_button_width, self.base_position_rect.height)), self.selected_option, self.ui_manager, self.ui_container, starting_height=2, parent_element=self.drop_down_menu_ui, object_id='#selected_option') open_button_x = self.base_position_rect.x + self.base_position_rect.width - self.open_button_width expand_direction = self.ui_manager.get_theme().get_misc_data( self.object_ids, self.element_ids, 'expand_direction') expand_button_symbol = '▼' if expand_direction is not None: if expand_direction == 'up': expand_button_symbol = '▲' elif expand_direction == 'down': expand_button_symbol = '▼' self.open_button = UIButton(pygame.Rect( (open_button_x, self.base_position_rect.y), (self.open_button_width, self.base_position_rect.height)), expand_button_symbol, self.ui_manager, self.ui_container, starting_height=2, parent_element=self.drop_down_menu_ui, object_id='#expand_button') def finish(self): """ Called when we leave the closed state. Kills the open button and the selected option button. """ self.selected_option_button.kill() self.open_button.kill() def update(self): if self.open_button.check_pressed(): self.should_transition = True
class UIExpandedDropDownState: """ The expanded state of the drop down displays the currently chosen option, all the available options and a button to close the menu and return to the closed state. Picking an option will also close the menu. """ def __init__(self, drop_down_menu_ui, options_list, selected_option, base_position_rect, close_button_width, manager, container, element_ids, object_ids): self.drop_down_menu_ui = drop_down_menu_ui self.should_transition = False self.options_list = options_list self.selected_option = selected_option self.base_position_rect = base_position_rect self.ui_manager = manager self.ui_container = container self.element_ids = element_ids self.object_ids = object_ids self.close_button_width = close_button_width self.selected_option_button = None self.close_button = None self.menu_buttons = [] self.should_transition = False self.target_state = 'closed' def start(self): """ Called each time we enter the expanded state. It creates the necessary elements, the selected option, all the other available options and the close button. """ self.should_transition = False option_y_pos = self.base_position_rect.y self.selected_option_button = UIButton( pygame.Rect( self.base_position_rect.topleft, (self.base_position_rect.width - self.close_button_width, self.base_position_rect.height)), self.selected_option, self.ui_manager, self.ui_container, starting_height=2, parent_element=self.drop_down_menu_ui, object_id='#selected_option') expand_direction = self.ui_manager.get_theme().get_misc_data( self.object_ids, self.element_ids, 'expand_direction') expand_button_symbol = '▼' select_button_dist_to_move = self.selected_option_button.rect.height option_button_dist_to_move = self.base_position_rect.height if expand_direction is not None: if expand_direction == 'up': expand_button_symbol = '▲' select_button_dist_to_move = -self.selected_option_button.rect.height option_button_dist_to_move = -self.base_position_rect.height elif expand_direction == 'down': expand_button_symbol = '▼' select_button_dist_to_move = self.selected_option_button.rect.height option_button_dist_to_move = self.base_position_rect.height close_button_x = self.base_position_rect.x + self.base_position_rect.width - self.close_button_width self.close_button = UIButton(pygame.Rect( (close_button_x, self.base_position_rect.y), (self.close_button_width, self.base_position_rect.height)), expand_button_symbol, self.ui_manager, self.ui_container, starting_height=2, parent_element=self.drop_down_menu_ui, object_id='#expand_button') option_y_pos += select_button_dist_to_move for option in self.options_list: new_button = UIButton( pygame.Rect( (self.base_position_rect.x, option_y_pos), (self.base_position_rect.width - self.close_button_width, self.base_position_rect.height)), option, self.ui_manager, self.ui_container, starting_height= 3, # height allows options to overlap other UI elements parent_element=self.drop_down_menu_ui, object_id='#option') option_y_pos += option_button_dist_to_move self.menu_buttons.append(new_button) overall_background_rect = pygame.Rect( self.drop_down_menu_ui.rect.topleft, (self.drop_down_menu_ui.rect.width + 50, self.base_position_rect.height * (1 + len(self.options_list)) + 2 * self.drop_down_menu_ui.shadow_width + 2 * self.drop_down_menu_ui.border_width)) options_background_rect = pygame.Rect( self.drop_down_menu_ui.rect.topleft, (self.base_position_rect.width - self.close_button_width + 2 * self.drop_down_menu_ui.shadow_width + 2 * self.drop_down_menu_ui.border_width, self.base_position_rect.height * (1 + len(self.options_list)) + 2 * self.drop_down_menu_ui.shadow_width + 2 * self.drop_down_menu_ui.border_width)) self.drop_down_menu_ui.image = pygame.Surface( overall_background_rect.size, flags=pygame.SRCALPHA) self.drop_down_menu_ui.image.fill(pygame.Color('#00000000')) if self.drop_down_menu_ui.shadow_width > 0: self.drop_down_menu_ui.image.blit( self.ui_manager.get_shadow(self.drop_down_menu_ui.rect.size), (0, 0)) border_rect = pygame.Rect((self.drop_down_menu_ui.shadow_width, self.drop_down_menu_ui.shadow_width), (self.drop_down_menu_ui.rect.width - (2 * self.drop_down_menu_ui.shadow_width), self.drop_down_menu_ui.rect.height - (2 * self.drop_down_menu_ui.shadow_width))) if self.drop_down_menu_ui.border_width > 0: self.drop_down_menu_ui.image.fill( self.drop_down_menu_ui.border_colour, border_rect) relative_background_rect = pygame.Rect( (self.drop_down_menu_ui.border_width + self.drop_down_menu_ui.shadow_width, self.drop_down_menu_ui.border_width + self.drop_down_menu_ui.shadow_width), (border_rect.width - (2 * self.drop_down_menu_ui.border_width), border_rect.height - (2 * self.drop_down_menu_ui.border_width))) self.drop_down_menu_ui.image.fill( self.drop_down_menu_ui.background_colour, relative_background_rect) if self.drop_down_menu_ui.shadow_width > 0: self.drop_down_menu_ui.image.fill( pygame.Color('#00000000'), pygame.Rect((0, 0), (options_background_rect.width - self.drop_down_menu_ui.shadow_width - self.drop_down_menu_ui.border_width, options_background_rect.height))) self.drop_down_menu_ui.image.blit( self.ui_manager.get_shadow(options_background_rect.size), (0, 0)) options_border_rect = pygame.Rect( (self.drop_down_menu_ui.shadow_width, self.drop_down_menu_ui.shadow_width), (options_background_rect.width - (2 * self.drop_down_menu_ui.shadow_width), options_background_rect.height - (2 * self.drop_down_menu_ui.shadow_width))) if self.drop_down_menu_ui.border_width > 0: self.drop_down_menu_ui.image.fill( self.drop_down_menu_ui.border_colour, options_border_rect) options_background_rect = pygame.Rect( (self.drop_down_menu_ui.border_width + self.drop_down_menu_ui.shadow_width, self.drop_down_menu_ui.border_width + self.drop_down_menu_ui.shadow_width), (options_border_rect.width - (2 * self.drop_down_menu_ui.border_width), options_border_rect.height - (2 * self.drop_down_menu_ui.border_width))) self.drop_down_menu_ui.image.fill( self.drop_down_menu_ui.background_colour, options_background_rect) def finish(self): """ cleans everything up upon exiting the expanded menu state. """ for button in self.menu_buttons: button.kill() self.menu_buttons.clear() self.selected_option_button.kill() self.close_button.kill() def update(self): if self.close_button is not None and self.close_button.check_pressed(): self.should_transition = True for button in self.menu_buttons: if button.check_pressed(): self.drop_down_menu_ui.selected_option = button.text self.should_transition = True drop_down_changed_event = pygame.event.Event( pygame.USEREVENT, { 'user_type': 'ui_drop_down_menu_changed', 'text': button.text, 'ui_element': self.drop_down_menu_ui, 'ui_object_id': self.object_ids[-1] }) pygame.event.post(drop_down_changed_event)
class UIHorizontalScrollBar(UIElement): """ A horizontal scroll bar allows users to position a smaller visible area within a horizontally larger area. :param relative_rect: The size and position of the scroll bar. :param visible_percentage: The horizontal 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. """ def __init__(self, relative_rect: pygame.Rect, visible_percentage: float, manager: IUIManagerInterface, container: Union[IContainerLikeInterface, None] = None, parent_element: UIElement = None, object_id: Union[str, None] = None, anchors: Dict[str, str] = None): new_element_ids, new_object_ids = self._create_valid_ids(container=container, parent_element=parent_element, object_id=object_id, element_id='horizontal_scroll_bar') super().__init__(relative_rect, manager, container, layer_thickness=2, starting_height=1, element_ids=new_element_ids, object_ids=new_object_ids, anchors=anchors) self.button_width = 20 self.arrow_button_width = self.button_width self.scroll_position = 0.0 self.left_limit = 0.0 self.starting_grab_x_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_left = False self.scroll_wheel_right = False self.background_colour = None self.border_colour = None self.border_width = None self.shadow_width = None self.drawable_shape = None self.shape_type = 'rectangle' self.shape_corner_radius = None self.background_rect = None # type: Union[None, pygame.Rect] self.scrollable_width = None # type: Union[None, int, float] self.right_limit = None self.sliding_rect_position = None # type: Union[None, pygame.math.Vector2] self.left_button = None self.right_button = None self.sliding_button = None self.arrow_buttons_enabled = True self.button_container = None self.rebuild_from_changed_theme_data() scroll_bar_width = max(5, int(self.scrollable_width * self.visible_percentage)) self.sliding_button = UIButton(pygame.Rect((int(self.sliding_rect_position[0]), int(self.sliding_rect_position[1])), (scroll_bar_width, self.background_rect.height)), '', self.ui_manager, container=self.button_container, starting_height=1, parent_element=self, object_id="#sliding_button", anchors={'left': 'left', 'right': 'left', 'top': 'top', 'bottom': 'bottom'}) self.sliding_button.set_hold_range((self.background_rect.width, 100)) 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, 'border_width': self.border_width, 'shadow_width': self.shadow_width, 'shape_corner_radius': self.shape_corner_radius} if self.shape_type == 'rectangle': self.drawable_shape = RectDrawableShape(self.rect, theming_parameters, ['normal'], self.ui_manager) elif self.shape_type == 'rounded_rectangle': self.drawable_shape = RoundedRectangleShape(self.rect, theming_parameters, ['normal'], self.ui_manager) self.set_image(self.drawable_shape.get_surface('normal')) 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') else: self.button_container.set_dimensions(self.background_rect.size) self.button_container.set_relative_position(self.background_rect.topleft) if self.arrow_buttons_enabled: self.arrow_button_width = self.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="#left_button", anchors={'left': 'left', 'right': 'left', 'top': 'top', 'bottom': 'bottom'} ) 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="#right_button", anchors={'left': 'right', 'right': 'right', 'top': 'top', 'bottom': 'bottom'}) 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 - (2 * self.arrow_button_width) self.right_limit = self.scrollable_width scroll_bar_width = max(5, int(self.scrollable_width * self.visible_percentage)) self.scroll_position = min(max(self.scroll_position, self.left_limit), self.right_limit - scroll_bar_width) x_pos = (self.scroll_position + self.arrow_button_width) y_pos = 0 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((scroll_bar_width, self.background_rect.height)) self.sliding_button.set_hold_range((self.background_rect.width, 100)) 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 horizontal 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.ui_manager.clear_last_focused_from_horiz_scrollbar(self) self.ui_manager.clear_last_focused_from_horiz_scrollbar(self.sliding_button) self.ui_manager.clear_last_focused_from_horiz_scrollbar(self.left_button) self.ui_manager.clear_last_focused_from_horiz_scrollbar(self.right_button) self.button_container.kill() super().kill() def focus(self): """ When we focus the scroll bar as a whole for any reason we pass that status down to the 'bar' part of the scroll bar. """ if self.sliding_button is not None: self.ui_manager.set_focus_element(self.sliding_button) 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. """ # pygame.MOUSEWHEEL only defined after pygame 1.9 try: pygame.MOUSEWHEEL except AttributeError: pygame.MOUSEWHEEL = -1 consumed_event = False if self._check_was_last_focused() and event.type == pygame.MOUSEWHEEL: if event.x > 0: self.scroll_wheel_left = True consumed_event = True elif event.x < 0: self.scroll_wheel_right = True consumed_event = True return consumed_event def _check_was_last_focused(self) -> bool: """ Check if this scroll bar was the last one focused in the UI. :return: True if it was. """ last_focused_scrollbar_element = self.ui_manager.get_last_focused_horiz_scrollbar() return (last_focused_scrollbar_element is not None and ((last_focused_scrollbar_element is self) or (last_focused_scrollbar_element is self.sliding_button) or (last_focused_scrollbar_element is self.left_button) or (last_focused_scrollbar_element is self.right_button))) 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.left_button is not None and (self.left_button.held or self.scroll_wheel_left) and self.scroll_position > self.left_limit): self.scroll_wheel_left = False self.scroll_position -= (250.0 * time_delta) self.scroll_position = max(self.scroll_position, self.left_limit) 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 (self.right_button is not None and (self.right_button.held or self.scroll_wheel_right) and self.scroll_position < self.right_limit): self.scroll_wheel_right = False self.scroll_position += (250.0 * time_delta) self.scroll_position = min(self.scroll_position, self.right_limit - self.sliding_button.relative_rect.width) 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 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), self.right_limit - self.sliding_button.rect.width) 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.start_percentage = self.scroll_position / self.scrollable_width 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_width = self.background_rect.width - (2 * self.arrow_button_width) self.right_limit = self.scrollable_width scroll_bar_width = max(5, int(self.scrollable_width * self.visible_percentage)) x_pos = (self.scroll_position + self.arrow_button_width) y_pos = 0 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), scroll_bar_width, self.background_rect.height), '', self.ui_manager, container=self.button_container, starting_height=1, parent_element=self, object_id="#sliding_button", anchors={'left': 'left', 'right': 'left', 'top': 'top', 'bottom': 'bottom'}) else: self.sliding_button.set_relative_position(self.sliding_rect_position) self.sliding_button.set_dimensions((scroll_bar_width, self.background_rect.height)) self.sliding_button.set_hold_range((self.background_rect.width, 100)) 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 horizontal size of the 'scrollable area' or the horizontal 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. """ has_any_changed = False shape_type = 'rectangle' shape_type_string = self.ui_theme.get_misc_data(self.object_ids, self.element_ids, 'shape') if shape_type_string is not None and shape_type_string in ['rectangle', 'rounded_rectangle']: shape_type = shape_type_string if shape_type != self.shape_type: self.shape_type = shape_type has_any_changed = True if self._check_shape_theming_changed(defaults={'border_width': 1, 'shadow_width': 2, 'shape_corner_radius': 2}): has_any_changed = True background_colour = self.ui_theme.get_colour_or_gradient(self.object_ids, self.element_ids, 'dark_bg') if background_colour != self.background_colour: self.background_colour = background_colour has_any_changed = True border_colour = self.ui_theme.get_colour_or_gradient(self.object_ids, self.element_ids, 'normal_border') if border_colour != self.border_colour: self.border_colour = border_colour has_any_changed = True buttons_enable_param = self.ui_theme.get_misc_data(self.object_ids, self.element_ids, 'enable_arrow_buttons') if buttons_enable_param is not None: try: buttons_enable = bool(int(buttons_enable_param)) except ValueError: buttons_enable = True if buttons_enable != self.arrow_buttons_enabled: self.arrow_buttons_enabled = buttons_enable 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_width = self.background_rect.width - (2 * self.arrow_button_width) self.right_limit = self.scrollable_width scroll_bar_width = max(5, int(self.scrollable_width * self.visible_percentage)) base_scroll_bar_x = self.arrow_button_width max_scroll_bar_x = base_scroll_bar_x + (self.scrollable_width - scroll_bar_width) self.sliding_rect_position.x = max(base_scroll_bar_x, min((base_scroll_bar_x + int(self.start_percentage * self.scrollable_width)), max_scroll_bar_x)) self.scroll_position = self.sliding_rect_position.x - base_scroll_bar_x self.sliding_button.set_dimensions((scroll_bar_width, self.background_rect.height)) self.sliding_button.set_relative_position(self.sliding_rect_position)
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. """ def __init__(self, relative_rect: pygame.Rect, start_value: Union[float, int], value_range: Tuple[Union[float, int], Union[float, int]], manager: ui_manager.UIManager, container: ui_container.UIContainer = None, parent_element: UIElement = None, object_id: Union[str, None] = None): new_element_ids, new_object_ids = self.create_valid_ids(parent_element=parent_element, object_id=object_id, element_id='horizontal_slider') super().__init__(relative_rect, manager, container, object_ids=new_object_ids, element_ids=new_element_ids, starting_height=1, layer_thickness=1) self.button_width = 20 self.current_percentage = 0.5 self.value_range = value_range self.border_width = 1 border_width_string = self.ui_theme.get_misc_data(self.object_ids, self.element_ids, 'border_width') if border_width_string is not None: self.border_width = int(border_width_string) self.shadow_width = 1 shadow_width_string = self.ui_theme.get_misc_data(self.object_ids, self.element_ids, 'shadow_width') if shadow_width_string is not None: self.shadow_width = int(shadow_width_string) self.background_colour = self.ui_theme.get_colour(self.object_ids, self.element_ids, 'dark_bg') self.border_colour = self.ui_theme.get_colour(self.object_ids, self.element_ids, 'normal_border') if self.shadow_width > 0: self.image = self.ui_manager.get_shadow(self.rect.size) else: self.image = pygame.Surface(self.rect.size, flags=pygame.SRCALPHA) border_rect = pygame.Rect((self.shadow_width, self.shadow_width), (self.rect.width - (2 * self.shadow_width), self.rect.height - (2 * self.shadow_width))) if self.border_width > 0: self.image.fill(self.border_colour, border_rect) relative_background_rect = pygame.Rect((self.border_width + self.shadow_width, self.border_width + self.shadow_width), (border_rect.width - (2 * self.border_width), border_rect.height - (2 * self.border_width))) background_rect = pygame.Rect((relative_background_rect.x + relative_rect.x, relative_background_rect.y + relative_rect.y), relative_background_rect.size) self.image.fill(self.background_colour, relative_background_rect) value_range_length = self.value_range[1] - self.value_range[0] self.current_value = int(self.value_range[0] + (self.current_percentage * value_range_length)) self.scrollable_width = background_rect.width - (3 * self.button_width) self.left_limit_position = 0.0 self.right_limit_position = self.scrollable_width self.scroll_position = self.scrollable_width/2 self.left_button = UIButton(pygame.Rect(background_rect.topleft, (self.button_width, background_rect.height)), '◀', self.ui_manager, self.ui_container, starting_height=2, parent_element=self, object_id='#left_button') self.right_button = UIButton(pygame.Rect((background_rect.x + background_rect.width - self.button_width, background_rect.y), (self.button_width, background_rect.height)), '▶', self.ui_manager, self.ui_container, starting_height=2, parent_element=self, object_id='#right_button') sliding_x_pos = background_rect.x + background_rect.width/2 - self.button_width/2 self.sliding_button = UIButton(pygame.Rect((sliding_x_pos, background_rect.y), (self.button_width, background_rect.height)), '', self.ui_manager, self.ui_container, starting_height=2, parent_element=self, object_id='#sliding_button') self.sliding_button.set_hold_range((background_rect.width, 100)) self.grabbed_slider = False self.starting_grab_x_difference = 0 self.has_moved_recently = False self.set_current_value(start_value) def kill(self): """ Overrides the normal sprite kill() method to also kill the button elements that help make up the slider. """ self.left_button.kill() self.right_button.kill() self.sliding_button.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. """ if self.alive(): moved_this_frame = False if self.left_button.held and self.scroll_position > self.left_limit_position: self.scroll_position -= (250.0 * time_delta) self.scroll_position = max(self.scroll_position, self.left_limit_position) x_pos = self.scroll_position + self.rect.x + self.shadow_width + self.border_width + self.button_width y_pos = self.rect.y + self.shadow_width + self.border_width self.sliding_button.set_position(pygame.Vector2(x_pos, y_pos)) moved_this_frame = True elif self.right_button.held and self.scroll_position < self.right_limit_position: self.scroll_position += (250.0 * time_delta) self.scroll_position = min(self.scroll_position, self.right_limit_position) x_pos = self.scroll_position + self.rect.x + self.shadow_width + self.border_width + self.button_width y_pos = self.rect.y + self.shadow_width + self.border_width self.sliding_button.set_position(pygame.Vector2(x_pos, y_pos)) moved_this_frame = True mouse_x, mouse_y = pygame.mouse.get_pos() 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.scroll_position + self.rect.x + self.shadow_width + self.border_width + self.button_width) self.starting_grab_x_difference = mouse_x - real_scroll_pos real_scroll_pos = (self.scroll_position + self.rect.x + self.shadow_width + self.border_width + self.button_width) 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.rect.x + self.shadow_width + self.border_width + self.button_width y_pos = self.rect.y + self.shadow_width + self.border_width self.sliding_button.set_position(pygame.Vector2(x_pos, y_pos)) moved_this_frame = True elif not self.sliding_button.held: self.grabbed_slider = False if moved_this_frame: self.current_value = self.value_range[0] + ( (self.scroll_position / self.scrollable_width) * (self.value_range[1] - self.value_range[0])) if not self.has_moved_recently: self.has_moved_recently = True 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 return self.current_value def set_current_value(self, value: Union[float, int]): """ 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. """ if min(self.value_range[0], self.value_range[1]) <= value <= max(self.value_range[0], self.value_range[1]): self.current_value = float(value) value_range_size = (self.value_range[1] - self.value_range[0]) if value_range_size != 0: percentage = (self.current_value - self.value_range[0])/value_range_size self.scroll_position = self.scrollable_width * percentage x_pos = self.scroll_position + self.rect.x + self.shadow_width + self.border_width + self.button_width y_pos = self.rect.y + self.shadow_width + self.border_width self.sliding_button.set_position(pygame.Vector2(x_pos, y_pos)) self.has_moved_recently = True else: warnings.warn('value not in range', UserWarning)