From 7e827b78c87bbdb9aa743badfadd422dca9e46ab Mon Sep 17 00:00:00 2001 From: Dan Lawrence Date: Sun, 16 Oct 2022 16:14:22 +0100 Subject: [PATCH 01/14] text entry box first round of improvements --- pygame_gui/core/text/text_box_layout.py | 55 +++++++- pygame_gui/core/text/text_box_layout_row.py | 26 ++-- pygame_gui/core/text/text_line_chunk.py | 4 +- pygame_gui/elements/ui_text_box.py | 7 ++ pygame_gui/elements/ui_text_entry_box.py | 117 +++++++++++++++++- pygame_gui/elements/ui_text_entry_line.py | 10 +- pygame_gui/ui_manager.py | 2 + .../test_text/test_text_box_layout_row.py | 6 +- 8 files changed, 203 insertions(+), 24 deletions(-) diff --git a/pygame_gui/core/text/text_box_layout.py b/pygame_gui/core/text/text_box_layout.py index 2747c8ef..17540597 100644 --- a/pygame_gui/core/text/text_box_layout.py +++ b/pygame_gui/core/text/text_box_layout.py @@ -544,9 +544,26 @@ def set_cursor_position(self, cursor_pos): letter_acc = 0 for row in self.layout_rows: - if cursor_pos <= letter_acc + row.letter_count: + if cursor_pos < letter_acc + row.letter_count: row.set_cursor_position(cursor_pos - letter_acc) self.cursor_text_row = row + break + elif cursor_pos == letter_acc + row.letter_count: + # if the last character in a row is a space and we have more than one row + # we want to jump to the start of the next row + last_chunk = row.items[-1] + if len(self.layout_rows) > 1 and isinstance(last_chunk, TextLineChunkFTFont): + if len(last_chunk.text) > 0 and last_chunk.text[-1] == " ": + letter_acc += row.letter_count + else: + row.set_cursor_position(cursor_pos - letter_acc) + self.cursor_text_row = row + break + else: + row.set_cursor_position(cursor_pos - letter_acc) + self.cursor_text_row = row + break + else: letter_acc += row.letter_count @@ -562,11 +579,32 @@ def set_cursor_from_click_pos(self, click_pos): self.cursor_text_row.toggle_cursor() self.cursor_text_row = None - for row in self.layout_rows: - if click_pos[1] < row.top or click_pos[1] >= row.bottom: - continue + found_row_pos = False + for count, row in enumerate(self.layout_rows): + if count < len(self.layout_rows) - 1: + if click_pos[1] < row.top or click_pos[1] >= self.layout_rows[count+1].top: + continue + else: + if click_pos[1] < row.top or click_pos[1] > row.bottom: + continue + found_row_pos = True self.cursor_text_row = row - row.set_cursor_from_click_pos(click_pos) + row.set_cursor_from_click_pos(click_pos, len(self.layout_rows)) + break + if not found_row_pos and len(self.layout_rows) > 0: + if click_pos[1] > self.layout_rows[-1].bottom: + # we are assuming here that the rows are in height order + # TODO: check this is always true + self.cursor_text_row = self.layout_rows[-1] + new_cursor_pos = self.cursor_text_row.midright + self.cursor_text_row.set_cursor_from_click_pos(new_cursor_pos, len(self.layout_rows)) + + if click_pos[1] < self.layout_rows[0].top: + # we are assuming here that the rows are in height order + # TODO: check this is always true + self.cursor_text_row = self.layout_rows[0] + new_cursor_pos = (click_pos[0], self.cursor_text_row.centery) + self.cursor_text_row.set_cursor_from_click_pos(new_cursor_pos, len(self.layout_rows)) def get_cursor_index(self): """ @@ -577,7 +615,12 @@ def get_cursor_index(self): """ cursor_index = 0 if self.cursor_text_row is not None: - cursor_index = self.cursor_text_row.get_cursor_index() + for row in self.layout_rows: + if row == self.cursor_text_row: + cursor_index += self.cursor_text_row.get_cursor_index() + break + else: + cursor_index += row.letter_count return cursor_index def toggle_cursor(self): diff --git a/pygame_gui/core/text/text_box_layout_row.py b/pygame_gui/core/text/text_box_layout_row.py index 20d29b3d..4002e6d4 100644 --- a/pygame_gui/core/text/text_box_layout_row.py +++ b/pygame_gui/core/text/text_box_layout_row.py @@ -335,7 +335,7 @@ def _setup_offset_position_from_edit_cursor(self): self.layout.x_scroll_offset = max(0, self.cursor_draw_width - self.edit_cursor_left_margin) - def set_cursor_from_click_pos(self, click_pos: Tuple[int, int]): + def set_cursor_from_click_pos(self, click_pos: Tuple[int, int], num_rows: int): """ Set the current edit cursor position from a pixel position - usually originating from a mouse click. @@ -349,7 +349,13 @@ def set_cursor_from_click_pos(self, click_pos: Tuple[int, int]): for chunk in self.items: if isinstance(chunk, TextLineChunkFTFont): if not found_chunk: - if chunk.collidepoint(scrolled_click_pos): + # we only care about the X position at this point. + if chunk == self.items[0] and scrolled_click_pos[0] < chunk.left: + letter_index = 0 + cursor_draw_width = 0 + letter_acc += letter_index + found_chunk = True + elif chunk.collidepoint((scrolled_click_pos[0], chunk.centery)): letter_index = chunk.x_pos_to_letter_index(scrolled_click_pos[0]) cursor_draw_width += sum([char_metric[4] for char_metric in @@ -363,11 +369,17 @@ def set_cursor_from_click_pos(self, click_pos: Tuple[int, int]): chunk.font.get_metrics(chunk.text) if char_metric]) letter_acc += chunk.letter_count if not found_chunk: - # not inside chunk so move to start of line - # if we are on left, should be at end of line already - if scrolled_click_pos[0] < self.left: - cursor_draw_width = 0 - letter_acc = 0 + # not inside chunk + # if we have more than two rows check if we are on right of whole row and if row has space at the end. + # If so stick the edit cursor before the space because this is how it works. + if num_rows > 1 and scrolled_click_pos[0] >= self.right: + last_chunk = self.items[-1] + if isinstance(last_chunk, TextLineChunkFTFont): + if last_chunk.text[-1] == " ": + letter_acc -= 1 + char_metric = last_chunk.font.get_metrics(" ")[0] + if char_metric: + cursor_draw_width -= char_metric[4] self.cursor_draw_width = cursor_draw_width self.cursor_index = min(self.letter_count, max(0, letter_acc)) diff --git a/pygame_gui/core/text/text_line_chunk.py b/pygame_gui/core/text/text_line_chunk.py index fd4b294e..dea538b2 100644 --- a/pygame_gui/core/text/text_line_chunk.py +++ b/pygame_gui/core/text/text_line_chunk.py @@ -635,7 +635,9 @@ def x_pos_to_letter_index(self, x_pos: int): check_dir *= -1 changed_dir += 1 step = 1 - + if best_index == len(self.text): + if self.text[-1] == " ": + best_index = max(0, best_index - 1) return best_index def redraw(self): diff --git a/pygame_gui/elements/ui_text_box.py b/pygame_gui/elements/ui_text_box.py index b1605c5d..f013c47d 100644 --- a/pygame_gui/elements/ui_text_box.py +++ b/pygame_gui/elements/ui_text_box.py @@ -317,6 +317,13 @@ def rebuild(self): self.should_trigger_full_rebuild = False self.full_rebuild_countdown = self.time_until_full_rebuild_after_changing_size + def get_text_layout_top_left(self): + return (self.rect.left + self.padding[0] + self.border_width + + self.shadow_width + self.rounded_corner_offset, + self.rect.top + self.padding[1] + self.border_width + + self.shadow_width + self.rounded_corner_offset + ) + def _align_all_text_rows(self): """ Aligns the text drawing position correctly according to our theming options. diff --git a/pygame_gui/elements/ui_text_entry_box.py b/pygame_gui/elements/ui_text_entry_box.py index 2eff9a12..2feabd0b 100644 --- a/pygame_gui/elements/ui_text_entry_box.py +++ b/pygame_gui/elements/ui_text_entry_box.py @@ -1,6 +1,7 @@ from typing import Union, Tuple, Dict, Optional, Any -from pygame import Rect +from pygame import Rect, MOUSEBUTTONDOWN, BUTTON_LEFT +from pygame.event import Event from pygame_gui.core import ObjectID from pygame_gui.core.ui_element import UIElement from pygame_gui.core.interfaces import IContainerLikeInterface, IUIManagerInterface @@ -25,3 +26,117 @@ def __init__(self, object_id=object_id, anchors=anchors, visible=visible) + + # input timings - I expect nobody really wants to mess with these that much + # ideally we could populate from the os settings but that sounds like a headache + self.key_repeat = 0.5 + self.cursor_blink_delay_after_moving_acc = 0.0 + self.cursor_blink_delay_after_moving = 1.0 + self.blink_cursor_time_acc = 0.0 + self.blink_cursor_time = 0.4 + + self.double_click_timer = self.ui_manager.get_double_click_time() + 1.0 + + self.edit_position = 0 + self._select_range = [0, 0] + self.selection_in_progress = False + + self.cursor_on = False + self.cursor_has_moved_recently = False + + def update(self, time_delta: float): + """ + Called every update loop of our UI Manager. Largely handles text drag selection and + making sure our edit cursor blinks on and off. + + :param time_delta: The time in seconds between this update method call and the previous one. + + """ + super().update(time_delta) + + if not self.alive(): + return + scaled_mouse_pos = self.ui_manager.get_mouse_position() + if self.hover_point(scaled_mouse_pos[0], scaled_mouse_pos[1]): + self.ui_manager.set_text_input_hovered(True) + + if self.cursor_has_moved_recently: + self.cursor_has_moved_recently = False + self.cursor_blink_delay_after_moving_acc = 0.0 + self.cursor_on = True + self.text_box_layout.set_cursor_position(self.edit_position) + self.text_box_layout.toggle_cursor() + self.redraw_from_text_block() + + if self.cursor_blink_delay_after_moving_acc > self.cursor_blink_delay_after_moving: + if self.blink_cursor_time_acc >= self.blink_cursor_time: + self.blink_cursor_time_acc = 0.0 + if self.cursor_on: + self.cursor_on = False + self.text_box_layout.toggle_cursor() + self.redraw_from_text_block() + elif self.is_focused: + self.cursor_on = True + self.text_box_layout.toggle_cursor() + self.redraw_from_text_block() + else: + self.blink_cursor_time_acc += time_delta + else: + self.cursor_blink_delay_after_moving_acc += time_delta + + def process_event(self, event: Event) -> bool: + """ + Allows the text entry box to react to input events, which is it's primary function. + The entry element reacts to various types of mouse clicks (double click selecting words, + drag select), keyboard combos (CTRL+C, CTRL+V, CTRL+X, CTRL+A), individual editing keys + (Backspace, Delete, Left & Right arrows) and other keys for inputting letters, symbols + and numbers. + + :param event: The current event to consider reacting to. + + :return: Returns True if we've done something with the input event. + + """ + consumed_event = False + + if self._process_mouse_button_event(event): + consumed_event = True + + return consumed_event + + def _process_mouse_button_event(self, event: Event) -> bool: + """ + Process a mouse button event. + + :param event: Event to process. + + :return: True if we consumed the mouse event. + + """ + consumed_event = False + if event.type == MOUSEBUTTONDOWN and event.button == BUTTON_LEFT: + scaled_mouse_pos = self.ui_manager.calculate_scaled_mouse_position(event.pos) + if self.hover_point(scaled_mouse_pos[0], scaled_mouse_pos[1]): + if self.is_enabled: + text_layout_top_left = self.get_text_layout_top_left() + # need to account for scroll bar scrolling here - see example code + text_layout_space_pos = (scaled_mouse_pos[0] - text_layout_top_left[0], + scaled_mouse_pos[1] - text_layout_top_left[1]) + self.text_box_layout.set_cursor_from_click_pos(text_layout_space_pos) + self.edit_position = self.text_box_layout.get_cursor_index() + self.redraw_from_text_block() + + double_clicking = False + # if self.double_click_timer < self.ui_manager.get_double_click_time(): + # if self._calculate_double_click_word_selection(): + # double_clicking = True + + if not double_clicking: + self.select_range = [self.edit_position, self.edit_position] + self.cursor_has_moved_recently = True + self.selection_in_progress = True + self.double_click_timer = 0.0 + + consumed_event = True + + return consumed_event diff --git a/pygame_gui/elements/ui_text_entry_line.py b/pygame_gui/elements/ui_text_entry_line.py index ae89cd71..108003cd 100644 --- a/pygame_gui/elements/ui_text_entry_line.py +++ b/pygame_gui/elements/ui_text_entry_line.py @@ -1,7 +1,7 @@ import re import warnings -from typing import Union, List, Dict, Optional +from typing import Union, List, Dict, Optional, Tuple import pygame @@ -67,7 +67,7 @@ class UITextEntryLine(UIElement): _number_character_set['en'])} def __init__(self, - relative_rect: pygame.Rect, + relative_rect: Union[pygame.Rect, Tuple[int, int, int, int]], manager: Optional[IUIManagerInterface] = None, container: Optional[IContainerLikeInterface] = None, parent_element: Optional[UIElement] = None, @@ -309,8 +309,7 @@ def update(self, time_delta: float): scaled_mouse_pos = self.ui_manager.get_mouse_position() if self.hover_point(scaled_mouse_pos[0], scaled_mouse_pos[1]): self.ui_manager.set_text_input_hovered(True) - else: - self.ui_manager.set_text_input_hovered(False) + if self.double_click_timer < self.ui_manager.get_double_click_time(): self.double_click_timer += time_delta if self.selection_in_progress: @@ -774,8 +773,8 @@ def _calculate_double_click_word_selection(self): if index >= 0: char = self.text[index] # Check we clicked in the same place on a our second click. + pattern = re.compile(r"[\w']+") if index > 0: - pattern = re.compile(r"[\w']+") while not pattern.match(char): index -= 1 if index > 0: @@ -797,7 +796,6 @@ def _calculate_double_click_word_selection(self): if index < len(self.text): char = self.text[index] end_select_index = index - print([start_select_index, end_select_index]) self.select_range = [start_select_index, end_select_index] self.edit_position = end_select_index self.cursor_has_moved_recently = True diff --git a/pygame_gui/ui_manager.py b/pygame_gui/ui_manager.py index 086d7900..31ecb001 100644 --- a/pygame_gui/ui_manager.py +++ b/pygame_gui/ui_manager.py @@ -264,6 +264,8 @@ def update(self, time_delta: float): self._update_mouse_position() self._handle_hovering(time_delta) + self.set_text_input_hovered(False) # reset the text hovered status each loop + self.ui_group.update(time_delta) # handle mouse cursors diff --git a/tests/test_core/test_text/test_text_box_layout_row.py b/tests/test_core/test_text/test_text_box_layout_row.py index 98b33c3e..9b15b2e9 100644 --- a/tests/test_core/test_text/test_text_box_layout_row.py +++ b/tests/test_core/test_text/test_text_box_layout_row.py @@ -471,7 +471,7 @@ def test_set_cursor_from_click_pos(self): assert layout_surface.get_at((1, 5)) == pygame.Color('#FFFFFF') assert layout_row.cursor_index == 0 - layout_row.set_cursor_from_click_pos((44, 5)) + layout_row.set_cursor_from_click_pos((44, 5), num_rows=1) layout_row.toggle_cursor() layout_row.toggle_cursor() @@ -481,11 +481,11 @@ def test_set_cursor_from_click_pos(self): assert layout_row.cursor_index == 3 assert layout_row.cursor_draw_width == 44 - layout_row.set_cursor_from_click_pos((180, 5)) + layout_row.set_cursor_from_click_pos((180, 5), num_rows=1) assert layout_row.cursor_index == 4 - layout_row.set_cursor_from_click_pos((-1, 5)) + layout_row.set_cursor_from_click_pos((-1, 5), num_rows=1) assert layout_row.left == 0 assert layout_row.cursor_index == 0 From 1f50c98ff8e1c56322a70d25fa54bd70b78b6349 Mon Sep 17 00:00:00 2001 From: Dan Lawrence Date: Mon, 24 Oct 2022 21:03:12 +0100 Subject: [PATCH 02/14] Improvements to text entry box text selection and deletion --- pygame_gui/core/text/text_box_layout.py | 162 +++++++++--- pygame_gui/core/text/text_box_layout_row.py | 47 +++- pygame_gui/elements/ui_text_entry_box.py | 257 +++++++++++++++++++- pygame_gui/elements/ui_text_entry_line.py | 5 +- 4 files changed, 421 insertions(+), 50 deletions(-) diff --git a/pygame_gui/core/text/text_box_layout.py b/pygame_gui/core/text/text_box_layout.py index 17540597..6b93ca88 100644 --- a/pygame_gui/core/text/text_box_layout.py +++ b/pygame_gui/core/text/text_box_layout.py @@ -52,6 +52,7 @@ def __init__(self, self.floating_rects: List[TextLayoutRect] = [] self.layout_rows: List[TextBoxLayoutRow] = [] self.row_lengths = [] + self.row_lengths_no_end_spaces = [] self.link_chunks = [] self.letter_count = 0 self.current_end_pos = 0 @@ -436,7 +437,7 @@ def insert_layout_rects(self, layout_rects: Deque[TextLayoutRect], row.rewind_row(temp_layout_queue) self.layout_rows = self.layout_rows[:row_index] - + self._merge_adjacent_compatible_chunks(temp_layout_queue) self._process_layout_queue(temp_layout_queue, row) if self.finalised_surface is not None: @@ -567,6 +568,33 @@ def set_cursor_position(self, cursor_pos): else: letter_acc += row.letter_count + def _find_cursor_row_from_click(self, click_pos): + found_row = None + cursor_click_pos = (click_pos[0], click_pos[1]) + for count, row in enumerate(self.layout_rows): + if count < len(self.layout_rows) - 1: + if cursor_click_pos[1] < row.top or cursor_click_pos[1] >= self.layout_rows[count + 1].top: + continue + else: + if cursor_click_pos[1] < row.top or cursor_click_pos[1] > row.bottom: + continue + found_row = row + + if found_row is None and len(self.layout_rows) > 0: + if cursor_click_pos[1] > self.layout_rows[-1].bottom: + # we are assuming here that the rows are in height order + # TODO: check this is always true + found_row = self.layout_rows[-1] + cursor_click_pos = found_row.midright + + if cursor_click_pos[1] < self.layout_rows[0].top: + # we are assuming here that the rows are in height order + # TODO: check this is always true + found_row = self.layout_rows[0] + cursor_click_pos = (cursor_click_pos[0], found_row.centery) + + return found_row, cursor_click_pos + def set_cursor_from_click_pos(self, click_pos): """ Set the edit cursor position in the text layout from a pixel position. Generally used @@ -579,32 +607,34 @@ def set_cursor_from_click_pos(self, click_pos): self.cursor_text_row.toggle_cursor() self.cursor_text_row = None - found_row_pos = False - for count, row in enumerate(self.layout_rows): - if count < len(self.layout_rows) - 1: - if click_pos[1] < row.top or click_pos[1] >= self.layout_rows[count+1].top: - continue - else: - if click_pos[1] < row.top or click_pos[1] > row.bottom: - continue - found_row_pos = True - self.cursor_text_row = row - row.set_cursor_from_click_pos(click_pos, len(self.layout_rows)) - break - if not found_row_pos and len(self.layout_rows) > 0: - if click_pos[1] > self.layout_rows[-1].bottom: - # we are assuming here that the rows are in height order - # TODO: check this is always true - self.cursor_text_row = self.layout_rows[-1] - new_cursor_pos = self.cursor_text_row.midright - self.cursor_text_row.set_cursor_from_click_pos(new_cursor_pos, len(self.layout_rows)) + found_row, final_click_pos = self._find_cursor_row_from_click(click_pos) - if click_pos[1] < self.layout_rows[0].top: - # we are assuming here that the rows are in height order - # TODO: check this is always true - self.cursor_text_row = self.layout_rows[0] - new_cursor_pos = (click_pos[0], self.cursor_text_row.centery) - self.cursor_text_row.set_cursor_from_click_pos(new_cursor_pos, len(self.layout_rows)) + self.cursor_text_row = found_row + if self.cursor_text_row is not None: + self.cursor_text_row.set_cursor_from_click_pos(final_click_pos, len(self.layout_rows)) + + def find_cursor_position_from_click_pos(self, click_pos) -> int: + """ + Find an edit text cursor position in the text from a click. + + Here we don't set it, we just find it and return it. + + :param click_pos: This is the pixel position we want to find the nearest cursor spot to. + :return: an integer representing the character index position in the text + """ + found_row, final_click_pos = self._find_cursor_row_from_click(click_pos) + + if found_row is not None: + cursor_index = 0 + for row in self.layout_rows: + if row == found_row: + cursor_index += found_row.find_cursor_pos_from_click_pos(final_click_pos, len(self.layout_rows))[0] + break + else: + cursor_index += row.letter_count + return cursor_index + + return 0 def get_cursor_index(self): """ @@ -632,6 +662,20 @@ def toggle_cursor(self): if self.cursor_text_row is not None: self.cursor_text_row.toggle_cursor() + def turn_off_cursor(self): + """ + Makes the edit test cursor invisible. + """ + if self.cursor_text_row is not None: + self.cursor_text_row.turn_off_cursor() + + def turn_on_cursor(self): + """ + Makes the edit test cursor visible. + """ + if self.cursor_text_row is not None: + self.cursor_text_row.turn_on_cursor() + def set_text_selection(self, start_index, end_index): """ Set a portion of the text layout as 'selected'. This is useful when editing chunks @@ -706,6 +750,17 @@ def _find_and_split_chunk(self, index: int, return_rhs: bool = False): break letter_accumulator += chunk.letter_count chunk_in_row_index += 1 + if found_chunk is None: + # couldn't find it on this row so use the first chunk of row below + if row_index + 1 < len(self.layout_rows): + chunk_row = self.layout_rows[row_index+1] + row_index = chunk_row.row_index + letter_index = 0 + + for chunk in chunk_row.items: + if isinstance(chunk, TextLineChunkFTFont): + found_chunk = chunk + break if letter_index != 0: # split the chunk @@ -756,7 +811,7 @@ def insert_text(self, text: str, layout_index: int, parser: Optional[HTMLParser] row.rewind_row(temp_layout_queue) self.layout_rows = self.layout_rows[:current_row.row_index] - + self._merge_adjacent_compatible_chunks(temp_layout_queue) self._process_layout_queue(temp_layout_queue, current_row) if self.finalised_surface is not None: @@ -788,15 +843,22 @@ def delete_selected_text(self): current_row_index = current_row.row_index for row in reversed(self.selected_rows): row.items = [chunk for chunk in row.items if not chunk.is_selected] - row.rewind_row(temp_layout_queue) + # row.rewind_row(temp_layout_queue) if row.row_index > max_row_index: max_row_index = row.row_index - for row_index in reversed(range(max_row_index + 1, len(self.layout_rows))): + for row_index in reversed(range(current_row_index, len(self.layout_rows))): self.layout_rows[row_index].rewind_row(temp_layout_queue) - self.layout_rows = self.layout_rows[:current_row_index] + # clear out rows that may now be empty first + newly_empty_rows = self.layout_rows[current_row_index:] + if self.finalised_surface is not None: + for row in newly_empty_rows: + row.clear() + + self.layout_rows = self.layout_rows[:current_row_index] + self._merge_adjacent_compatible_chunks(temp_layout_queue) self._process_layout_queue(temp_layout_queue, current_row) if len(current_row.items) == 0: @@ -824,20 +886,31 @@ def delete_at_cursor(self): current_row_index = current_row.row_index cursor_pos = self.cursor_text_row.cursor_index letter_acc = 0 + deleted_character = False for chunk in self.cursor_text_row.items: - if cursor_pos <= letter_acc + (chunk.letter_count - 1): - chunk_letter_pos = cursor_pos - letter_acc - chunk.delete_letter_at_index(chunk_letter_pos) - break + if isinstance(chunk, TextLineChunkFTFont): + if cursor_pos <= letter_acc + (chunk.letter_count - 1): + chunk_letter_pos = cursor_pos - letter_acc + chunk.delete_letter_at_index(chunk_letter_pos) + deleted_character = True + break - letter_acc += chunk.letter_count + letter_acc += chunk.letter_count + if not deleted_character: + # failed to delete character, must be at end of row - see if we have a row below + # if so delete the first character of that row + if current_row_index + 1 < len(self.layout_rows): + row_below = self.layout_rows[current_row_index + 1] + for chunk in row_below.items: + if isinstance(chunk, TextLineChunkFTFont): + chunk.delete_letter_at_index(0) temp_layout_queue = deque([]) for row_index in reversed(range(current_row_index, len(self.layout_rows))): self.layout_rows[row_index].rewind_row(temp_layout_queue) self.layout_rows = self.layout_rows[:current_row_index] - + self._merge_adjacent_compatible_chunks(temp_layout_queue) self._process_layout_queue(temp_layout_queue, current_row) if self.finalised_surface is not None: @@ -867,7 +940,7 @@ def backspace_at_cursor(self): self.layout_rows[row_index].rewind_row(temp_layout_queue) self.layout_rows = self.layout_rows[:current_row_index] - + self._merge_adjacent_compatible_chunks(temp_layout_queue) self._process_layout_queue(temp_layout_queue, current_row) if self.finalised_surface is not None: @@ -951,3 +1024,18 @@ def get_cursor_colour(self) -> pygame.Color: :return: a pygame.Color object containing the current colour. """ return self.cursor_colour + + @staticmethod + def _merge_adjacent_compatible_chunks(chunk_list: deque): + + index = 0 + while index < len(chunk_list)-1: + current_item = chunk_list[index] + next_item = chunk_list[index+1] + if (isinstance(current_item, TextLineChunkFTFont) and + isinstance(next_item, TextLineChunkFTFont) and + current_item.style_match(next_item)): + current_item.add_text(next_item.text) + del chunk_list[index+1] + else: + index += 1 diff --git a/pygame_gui/core/text/text_box_layout_row.py b/pygame_gui/core/text/text_box_layout_row.py index 4002e6d4..836c3e37 100644 --- a/pygame_gui/core/text/text_box_layout_row.py +++ b/pygame_gui/core/text/text_box_layout_row.py @@ -303,6 +303,7 @@ def toggle_cursor(self): Generally used to make it flash on and off to catch the attention of the user. """ + if self.edit_cursor_active: self.edit_cursor_active = False else: @@ -312,6 +313,24 @@ def toggle_cursor(self): self.clear() self.finalise(self.target_surface) + def turn_off_cursor(self): + """ + Makes the edit test cursor invisible. + """ + self.edit_cursor_active = False + if self.target_surface is not None: + self.clear() + self.finalise(self.target_surface) + + def turn_on_cursor(self): + """ + Makes the edit test cursor visible. + """ + self.edit_cursor_active = True + if self.target_surface is not None: + self.clear() + self.finalise(self.target_surface) + def clear(self): """ 'Clears' the current row from it's target surface by setting the @@ -340,6 +359,19 @@ def set_cursor_from_click_pos(self, click_pos: Tuple[int, int], num_rows: int): Set the current edit cursor position from a pixel position - usually originating from a mouse click. + :param num_rows: + :param click_pos: The pixel position to use. + """ + self.cursor_index, self.cursor_draw_width = self.find_cursor_pos_from_click_pos(click_pos, num_rows) + + self._setup_offset_position_from_edit_cursor() + + def find_cursor_pos_from_click_pos(self, click_pos: Tuple[int, int], num_rows: int): + """ + Find an edit cursor position from a pixel position - usually + originating from a mouse click. + + :param num_rows: :param click_pos: The pixel position to use. """ letter_acc = 0 @@ -380,10 +412,19 @@ def set_cursor_from_click_pos(self, click_pos: Tuple[int, int], num_rows: int): char_metric = last_chunk.font.get_metrics(" ")[0] if char_metric: cursor_draw_width -= char_metric[4] - self.cursor_draw_width = cursor_draw_width - self.cursor_index = min(self.letter_count, max(0, letter_acc)) - self._setup_offset_position_from_edit_cursor() + cursor_index = min(self.letter_count, max(0, letter_acc)) + + return cursor_index, cursor_draw_width + + def get_last_text_chunk(self): + last_item = None + for item in reversed(self.items): + if isinstance(item, TextLineChunkFTFont): + last_item = item + break + + return last_item def set_cursor_position(self, cursor_pos): """ diff --git a/pygame_gui/elements/ui_text_entry_box.py b/pygame_gui/elements/ui_text_entry_box.py index 2feabd0b..ec593438 100644 --- a/pygame_gui/elements/ui_text_entry_box.py +++ b/pygame_gui/elements/ui_text_entry_box.py @@ -1,7 +1,11 @@ +import re from typing import Union, Tuple, Dict, Optional, Any -from pygame import Rect, MOUSEBUTTONDOWN, BUTTON_LEFT -from pygame.event import Event +from pygame import Rect, MOUSEBUTTONDOWN, MOUSEBUTTONUP, BUTTON_LEFT, KEYDOWN +from pygame import K_LEFT, K_RIGHT, K_HOME, K_END, K_BACKSPACE, K_DELETE +from pygame import key +from pygame.event import Event, post +from pygame_gui._constants import UI_TEXT_ENTRY_CHANGED from pygame_gui.core import ObjectID from pygame_gui.core.ui_element import UIElement from pygame_gui.core.interfaces import IContainerLikeInterface, IUIManagerInterface @@ -44,6 +48,48 @@ def __init__(self, self.cursor_on = False self.cursor_has_moved_recently = False + @property + def select_range(self): + """ + The selected range for this text. A tuple containing the start + and end indexes of the current selection. + + Made into a property to keep it synchronised with the underlying drawable shape's + representation. + """ + return self._select_range + + @select_range.setter + def select_range(self, value): + self._select_range = value + start_select = min(self._select_range[0], self._select_range[1]) + end_select = max(self._select_range[0], self._select_range[1]) + + if start_select == 42: + print("Selecting: ", start_select, " ", end_select) + self.text_box_layout.set_text_selection(start_select, end_select) + self.redraw_from_text_block() + + def unfocus(self): + """ + Called when this element is no longer the current focus. + """ + super().unfocus() + key.set_repeat(0) + self.select_range = [0, 0] + self.edit_position = 0 + self.cursor_on = False + self.text_box_layout.turn_off_cursor() + self.redraw_from_text_block() + + def focus(self): + """ + Called when we 'select focus' on this element. In this case it sets up the keyboard to + repeat held key presses, useful for natural feeling keyboard input. + """ + super().focus() + key.set_repeat(500, 25) + def update(self, time_delta: float): """ Called every update loop of our UI Manager. Largely handles text drag selection and @@ -60,12 +106,29 @@ def update(self, time_delta: float): if self.hover_point(scaled_mouse_pos[0], scaled_mouse_pos[1]): self.ui_manager.set_text_input_hovered(True) + if self.double_click_timer < self.ui_manager.get_double_click_time(): + self.double_click_timer += time_delta + + if self.selection_in_progress: + text_layout_top_left = self.get_text_layout_top_left() + # TODO: need to account for scroll bar scrolling here - see example code + text_layout_space_pos = (scaled_mouse_pos[0] - text_layout_top_left[0], + scaled_mouse_pos[1] - text_layout_top_left[1]) + + select_end_pos = self.text_box_layout.find_cursor_position_from_click_pos(text_layout_space_pos) + new_range = [self.select_range[0], select_end_pos] + if new_range[0] != self.select_range[0] or new_range[1] != self.select_range[1]: + self.select_range = [new_range[0], new_range[1]] + + self.edit_position = self.select_range[1] + self.cursor_has_moved_recently = True + if self.cursor_has_moved_recently: self.cursor_has_moved_recently = False self.cursor_blink_delay_after_moving_acc = 0.0 self.cursor_on = True self.text_box_layout.set_cursor_position(self.edit_position) - self.text_box_layout.toggle_cursor() + self.text_box_layout.turn_on_cursor() self.redraw_from_text_block() if self.cursor_blink_delay_after_moving_acc > self.cursor_blink_delay_after_moving: @@ -98,12 +161,130 @@ def process_event(self, event: Event) -> bool: """ consumed_event = False + initial_text_state = self.html_text if self._process_mouse_button_event(event): consumed_event = True + if self.is_enabled and self.is_focused and event.type == KEYDOWN: + # if self._process_keyboard_shortcut_event(event): + # consumed_event = True + if self._process_action_key_event(event): + consumed_event = True + # elif self._process_text_entry_key(event): + # consumed_event = True + + if self.html_text != initial_text_state: + # new event + event_data = {'text': self.html_text, + 'ui_element': self, + 'ui_object_id': self.most_specific_combined_id} + post(Event(UI_TEXT_ENTRY_CHANGED, event_data)) + + return consumed_event + + def _process_action_key_event(self, event: Event) -> bool: + """ + Check if event is one of the keys that triggers an action like deleting, or moving + the edit position. + + :param event: The event to check. + + :return: True if event is consumed. + + """ + consumed_event = False + + if event.key == K_BACKSPACE: + if abs(self.select_range[0] - self.select_range[1]) > 0: + self.text_box_layout.delete_selected_text() + self.redraw_from_text_block() + low_end = min(self.select_range[0], self.select_range[1]) + high_end = max(self.select_range[0], self.select_range[1]) + self.html_text = self.html_text[:low_end] + self.html_text[high_end:] + self.edit_position = low_end + self.select_range = [0, 0] + self.cursor_has_moved_recently = True + + self.text_box_layout.set_cursor_position(self.edit_position) + self.redraw_from_text_block() + elif self.edit_position > 0: + self.html_text = self.html_text[:self.edit_position - 1] + self.html_text[self.edit_position:] + self.edit_position -= 1 + self.cursor_has_moved_recently = True + + self.text_box_layout.backspace_at_cursor() + self.text_box_layout.set_cursor_position(self.edit_position) + self.redraw_from_text_block() + + consumed_event = True + elif event.key == K_DELETE: + if abs(self.select_range[0] - self.select_range[1]) > 0: + self.text_box_layout.delete_selected_text() + self.redraw_from_text_block() + low_end = min(self.select_range[0], self.select_range[1]) + high_end = max(self.select_range[0], self.select_range[1]) + self.html_text = self.html_text[:low_end] + self.html_text[high_end:] + self.edit_position = low_end + self.select_range = [0, 0] + self.cursor_has_moved_recently = True + self.text_box_layout.set_cursor_position(self.edit_position) + self.redraw_from_text_block() + + elif self.edit_position < len(self.html_text): + self.html_text = self.html_text[:self.edit_position] + self.html_text[self.edit_position + 1:] + self.edit_position = self.edit_position + self.cursor_has_moved_recently = True + + self.text_box_layout.delete_at_cursor() + self.redraw_from_text_block() + consumed_event = True + elif self._process_edit_pos_move_key(event): + consumed_event = True return consumed_event + def _process_edit_pos_move_key(self, event: Event) -> bool: + """ + Process an action key that is moving the cursor edit position. + + :param event: The event to process. + + :return: True if event is consumed. + + """ + consumed_event = False + if event.key == K_LEFT: + if abs(self.select_range[0] - self.select_range[1]) > 0: + self.edit_position = min(self.select_range[0], self.select_range[1]) + self.select_range = [0, 0] + self.cursor_has_moved_recently = True + elif self.edit_position > 0: + self.edit_position -= 1 + self.cursor_has_moved_recently = True + consumed_event = True + elif event.key == K_RIGHT: + if abs(self.select_range[0] - self.select_range[1]) > 0: + self.edit_position = max(self.select_range[0], self.select_range[1]) + self.select_range = [0, 0] + self.cursor_has_moved_recently = True + elif self.edit_position < len(self.html_text): + self.edit_position += 1 + self.cursor_has_moved_recently = True + consumed_event = True + elif event.key == K_HOME: + if abs(self.select_range[0] - self.select_range[1]) > 0: + self.select_range = [0, 0] + self.edit_position = 0 + self.cursor_has_moved_recently = True + consumed_event = True + elif event.key == K_END: + if abs(self.select_range[0] - self.select_range[1]) > 0: + self.select_range = [0, 0] + self.edit_position = len(self.html_text) + self.cursor_has_moved_recently = True + consumed_event = True + return consumed_event + def _process_mouse_button_event(self, event: Event) -> bool: """ Process a mouse button event. @@ -119,7 +300,7 @@ def _process_mouse_button_event(self, event: Event) -> bool: if self.hover_point(scaled_mouse_pos[0], scaled_mouse_pos[1]): if self.is_enabled: text_layout_top_left = self.get_text_layout_top_left() - # need to account for scroll bar scrolling here - see example code + # TODO: need to account for scroll bar scrolling here - see example code text_layout_space_pos = (scaled_mouse_pos[0] - text_layout_top_left[0], scaled_mouse_pos[1] - text_layout_top_left[1]) self.text_box_layout.set_cursor_from_click_pos(text_layout_space_pos) @@ -127,9 +308,9 @@ def _process_mouse_button_event(self, event: Event) -> bool: self.redraw_from_text_block() double_clicking = False - # if self.double_click_timer < self.ui_manager.get_double_click_time(): - # if self._calculate_double_click_word_selection(): - # double_clicking = True + if self.double_click_timer < self.ui_manager.get_double_click_time(): + if self._calculate_double_click_word_selection(): + double_clicking = True if not double_clicking: self.select_range = [self.edit_position, self.edit_position] @@ -139,4 +320,66 @@ def _process_mouse_button_event(self, event: Event) -> bool: consumed_event = True + if (event.type == MOUSEBUTTONUP and + event.button == BUTTON_LEFT and + self.selection_in_progress): + scaled_mouse_pos = self.ui_manager.calculate_scaled_mouse_position(event.pos) + + if self.hover_point(scaled_mouse_pos[0], scaled_mouse_pos[1]): + consumed_event = True + text_layout_top_left = self.get_text_layout_top_left() + # TODO: need to account for scroll bar scrolling here - see example code + text_layout_space_pos = (scaled_mouse_pos[0] - text_layout_top_left[0], + scaled_mouse_pos[1] - text_layout_top_left[1]) + self.text_box_layout.set_cursor_from_click_pos(text_layout_space_pos) + new_edit_pos = self.text_box_layout.get_cursor_index() + if new_edit_pos != self.edit_position: + self.edit_position = new_edit_pos + self.cursor_has_moved_recently = True + self.select_range = [self.select_range[0], self.edit_position] + self.redraw_from_text_block() + self.selection_in_progress = False + return consumed_event + + def _calculate_double_click_word_selection(self): + """ + If we double clicked on a word in the text, select that word. + + """ + if self.edit_position != self.select_range[0]: + return False + index = min(self.edit_position, len(self.html_text) - 1) + if index >= 0: + char = self.html_text[index] + # Check we clicked in the same place on a our second click. + pattern = re.compile(r"[\w']+") + + while index >= 0 and not pattern.match(char): + index -= 1 + if index >= 0: + char = self.html_text[index] + else: + break + while index >= 0 and pattern.match(char): + index -= 1 + if index >= 0: + char = self.html_text[index] + else: + break + start_select_index = index + 1 + index += 1 + if index < len(self.html_text): + char = self.html_text[index] + while index < len(self.html_text) and pattern.match(char): + index += 1 + if index < len(self.html_text): + char = self.html_text[index] + end_select_index = index + self.select_range = [start_select_index, end_select_index] + self.edit_position = end_select_index + self.cursor_has_moved_recently = True + self.selection_in_progress = False + return True + else: + return False diff --git a/pygame_gui/elements/ui_text_entry_line.py b/pygame_gui/elements/ui_text_entry_line.py index 108003cd..56df533f 100644 --- a/pygame_gui/elements/ui_text_entry_line.py +++ b/pygame_gui/elements/ui_text_entry_line.py @@ -313,9 +313,8 @@ def update(self, time_delta: float): if self.double_click_timer < self.ui_manager.get_double_click_time(): self.double_click_timer += time_delta if self.selection_in_progress: - mouse_pos = self.ui_manager.get_mouse_position() - drawable_shape_space_click = (mouse_pos[0] - self.rect.left, - mouse_pos[1] - self.rect.top) + drawable_shape_space_click = (scaled_mouse_pos[0] - self.rect.left, + scaled_mouse_pos[1] - self.rect.top) if self.drawable_shape is not None: self.drawable_shape.text_box_layout.set_cursor_from_click_pos( drawable_shape_space_click) From f3691c0891346359a57336ef2c656f0983cbd363 Mon Sep 17 00:00:00 2001 From: Dan Lawrence Date: Tue, 25 Oct 2022 21:05:03 +0100 Subject: [PATCH 03/14] Improvements to text entry box text scrolling and cursor control --- pygame_gui/core/text/text_box_layout.py | 72 ++++++++++++++++++- pygame_gui/core/text/text_box_layout_row.py | 8 ++- pygame_gui/core/text/text_line_chunk.py | 5 +- pygame_gui/elements/ui_text_box.py | 14 ++-- pygame_gui/elements/ui_text_entry_box.py | 69 +++++++++++++----- pygame_gui/elements/ui_vertical_scroll_bar.py | 2 +- 6 files changed, 141 insertions(+), 29 deletions(-) diff --git a/pygame_gui/core/text/text_box_layout.py b/pygame_gui/core/text/text_box_layout.py index 6b93ca88..84866c8a 100644 --- a/pygame_gui/core/text/text_box_layout.py +++ b/pygame_gui/core/text/text_box_layout.py @@ -550,11 +550,13 @@ def set_cursor_position(self, cursor_pos): self.cursor_text_row = row break elif cursor_pos == letter_acc + row.letter_count: - # if the last character in a row is a space and we have more than one row + # if the last character in a row is a space, we have more than one row and this isn't the last row # we want to jump to the start of the next row last_chunk = row.items[-1] if len(self.layout_rows) > 1 and isinstance(last_chunk, TextLineChunkFTFont): - if len(last_chunk.text) > 0 and last_chunk.text[-1] == " ": + if (len(last_chunk.text) > 0 and + last_chunk.text[-1] == " " and + row.row_index != (len(self.layout_rows) - 1)): letter_acc += row.letter_count else: row.set_cursor_position(cursor_pos - letter_acc) @@ -1039,3 +1041,69 @@ def _merge_adjacent_compatible_chunks(chunk_list: deque): del chunk_list[index+1] else: index += 1 + + def fit_layout_rect_height_to_rows(self): + if len(self.layout_rows) > 0: + self.layout_rect.height = self.layout_rows[-1].bottom - self.layout_rect.top + + def get_cursor_y_pos(self): + if self.cursor_text_row is not None: + return self.cursor_text_row.top, self.cursor_text_row.bottom + else: + return 0, 0 + + def get_cursor_pos_move_up_one_row(self): + """ + Returns a cursor character position in the row directly above the current cursor position + if possible. + """ + if self.cursor_text_row is not None: + cursor_index = 0 + if self.cursor_text_row is not None: + for i in range(0, len(self.layout_rows)): + row = self.layout_rows[i] + if row == self.cursor_text_row: + if (i - 1) >= 0: + row_above = self.layout_rows[i-1] + cursor_index -= row_above.letter_count + row_above_end = row_above.letter_count + if row_above.row_text_ends_with_a_space(): + row_above_end = row_above.letter_count - 1 + cursor_index += min(self.cursor_text_row.get_cursor_index(), row_above_end) + break + else: + cursor_index += self.cursor_text_row.get_cursor_index() + break + else: + cursor_index += row.letter_count + return cursor_index + return 0 + + def get_cursor_pos_move_down_one_row(self): + """ + Returns a cursor character position in the row directly above the current cursor position + if possible. + """ + if self.cursor_text_row is not None: + cursor_index = 0 + if self.cursor_text_row is not None: + for i in range(0, len(self.layout_rows)): + row = self.layout_rows[i] + if row == self.cursor_text_row: + if (i + 1) < len(self.layout_rows): + row_below = self.layout_rows[i+1] + cursor_index += row.letter_count + row_below_end = row_below.letter_count + if row_below.row_text_ends_with_a_space(): + row_below_end = row_below.letter_count - 1 + cursor_index += min(self.cursor_text_row.get_cursor_index(), row_below_end) + break + else: + cursor_index += self.cursor_text_row.get_cursor_index() + break + else: + cursor_index += row.letter_count + return cursor_index + return 0 + + diff --git a/pygame_gui/core/text/text_box_layout_row.py b/pygame_gui/core/text/text_box_layout_row.py index 836c3e37..29fe7f55 100644 --- a/pygame_gui/core/text/text_box_layout_row.py +++ b/pygame_gui/core/text/text_box_layout_row.py @@ -414,7 +414,6 @@ def find_cursor_pos_from_click_pos(self, click_pos: Tuple[int, int], num_rows: i cursor_draw_width -= char_metric[4] cursor_index = min(self.letter_count, max(0, letter_acc)) - return cursor_index, cursor_draw_width def get_last_text_chunk(self): @@ -485,3 +484,10 @@ def insert_text(self, text: str, letter_row_index: int, else: raise AttributeError("Trying to insert into empty text row with no Parser" " for style data - fix this later?") + + def row_text_ends_with_a_space(self): + for item in reversed(self.items): + if isinstance(item, TextLineChunkFTFont): + if len(item.text) > 0 and item.text[-1] == " ": + return True + return False diff --git a/pygame_gui/core/text/text_line_chunk.py b/pygame_gui/core/text/text_line_chunk.py index dea538b2..edb1e67f 100644 --- a/pygame_gui/core/text/text_line_chunk.py +++ b/pygame_gui/core/text/text_line_chunk.py @@ -605,7 +605,7 @@ def backspace_letter_at_index(self, index): def x_pos_to_letter_index(self, x_pos: int): """ Convert a horizontal, or 'x' pixel position into a letter/character index in this - text chunk. Commonly used fro converting mouse clicks into letter positions for + text chunk. Commonly used for converting mouse clicks into letter positions for positioning the text editing cursor/carat. """ @@ -635,9 +635,6 @@ def x_pos_to_letter_index(self, x_pos: int): check_dir *= -1 changed_dir += 1 step = 1 - if best_index == len(self.text): - if self.text[-1] == " ": - best_index = max(0, best_index - 1) return best_index def redraw(self): diff --git a/pygame_gui/elements/ui_text_box.py b/pygame_gui/elements/ui_text_box.py index f013c47d..95f4159c 100644 --- a/pygame_gui/elements/ui_text_box.py +++ b/pygame_gui/elements/ui_text_box.py @@ -1,5 +1,6 @@ import warnings import math +import html from typing import Union, Tuple, Dict, Optional, Any @@ -99,7 +100,7 @@ def __init__(self, object_id=object_id, element_id='text_box') - self.html_text = html_text + self.html_text = html.unescape(html_text) self.appended_text = "" self.text_kwargs = {} if text_kwargs is not None: @@ -561,7 +562,12 @@ def redraw_from_text_block(self): self.text_box_layout.layout_rect.height) percentage_visible = (self.text_wrap_rect[3] / self.text_box_layout.layout_rect.height) - self.scroll_bar.set_visible_percentage(percentage_visible) + if percentage_visible >= 1.0: + self.scroll_bar.kill() + self.scroll_bar = None + height_adjustment = 0 + else: + self.scroll_bar.set_visible_percentage(percentage_visible) else: height_adjustment = 0 drawable_area_size = (max(1, (self.rect[2] - @@ -871,7 +877,7 @@ def append_html_text(self, new_html_str: str): :param new_html_str: The, potentially HTML tag, containing string of text to append. """ - self.appended_text += new_html_str + self.appended_text += html.unescape(new_html_str) self.parser.feed(self._pre_parse_text(new_html_str)) self.text_box_layout.append_layout_rects(self.parser.layout_rect_queue) self.parser.empty_layout_queue() @@ -1072,7 +1078,7 @@ def get_object_id(self) -> str: return self.most_specific_combined_id def set_text(self, html_text: str, *, text_kwargs: Optional[Dict[str, str]] = None): - self.html_text = html_text + self.html_text = html.unescape(html_text) if text_kwargs is not None: self.text_kwargs = text_kwargs else: diff --git a/pygame_gui/elements/ui_text_entry_box.py b/pygame_gui/elements/ui_text_entry_box.py index ec593438..96ffdc35 100644 --- a/pygame_gui/elements/ui_text_entry_box.py +++ b/pygame_gui/elements/ui_text_entry_box.py @@ -2,7 +2,7 @@ from typing import Union, Tuple, Dict, Optional, Any from pygame import Rect, MOUSEBUTTONDOWN, MOUSEBUTTONUP, BUTTON_LEFT, KEYDOWN -from pygame import K_LEFT, K_RIGHT, K_HOME, K_END, K_BACKSPACE, K_DELETE +from pygame import K_LEFT, K_RIGHT, K_UP, K_DOWN, K_HOME, K_END, K_BACKSPACE, K_DELETE from pygame import key from pygame.event import Event, post from pygame_gui._constants import UI_TEXT_ENTRY_CHANGED @@ -65,8 +65,6 @@ def select_range(self, value): start_select = min(self._select_range[0], self._select_range[1]) end_select = max(self._select_range[0], self._select_range[1]) - if start_select == 42: - print("Selecting: ", start_select, " ", end_select) self.text_box_layout.set_text_selection(start_select, end_select) self.redraw_from_text_block() @@ -104,17 +102,14 @@ def update(self, time_delta: float): return scaled_mouse_pos = self.ui_manager.get_mouse_position() if self.hover_point(scaled_mouse_pos[0], scaled_mouse_pos[1]): - self.ui_manager.set_text_input_hovered(True) + if self.scroll_bar is not None and not self.scroll_bar.hover_point(scaled_mouse_pos[0], scaled_mouse_pos[1]): + self.ui_manager.set_text_input_hovered(True) if self.double_click_timer < self.ui_manager.get_double_click_time(): self.double_click_timer += time_delta if self.selection_in_progress: - text_layout_top_left = self.get_text_layout_top_left() - # TODO: need to account for scroll bar scrolling here - see example code - text_layout_space_pos = (scaled_mouse_pos[0] - text_layout_top_left[0], - scaled_mouse_pos[1] - text_layout_top_left[1]) - + text_layout_space_pos = self._calculate_text_space_pos(scaled_mouse_pos) select_end_pos = self.text_box_layout.find_cursor_position_from_click_pos(text_layout_space_pos) new_range = [self.select_range[0], select_end_pos] if new_range[0] != self.select_range[0] or new_range[1] != self.select_range[1]: @@ -129,6 +124,21 @@ def update(self, time_delta: float): self.cursor_on = True self.text_box_layout.set_cursor_position(self.edit_position) self.text_box_layout.turn_on_cursor() + if self.scroll_bar is not None: + cursor_y_pos_top, cursor_y_pos_bottom = self.text_box_layout.get_cursor_y_pos() + current_height_adjustment = int(self.scroll_bar.start_percentage * + self.text_box_layout.layout_rect.height) + visible_bottom = current_height_adjustment + self.text_box_layout.view_rect.height + # handle cursor moving above current scroll position + if cursor_y_pos_top < current_height_adjustment: + new_start_percentage = cursor_y_pos_top / self.text_box_layout.layout_rect.height + self.scroll_bar.set_scroll_from_start_percentage(new_start_percentage) + # handle cursor moving below visible area + if cursor_y_pos_bottom > visible_bottom: + new_top = cursor_y_pos_bottom - self.text_box_layout.view_rect.height + new_start_percentage = new_top / self.text_box_layout.layout_rect.height + self.scroll_bar.set_scroll_from_start_percentage(new_start_percentage) + self.redraw_from_text_block() if self.cursor_blink_delay_after_moving_acc > self.cursor_blink_delay_after_moving: @@ -147,6 +157,15 @@ def update(self, time_delta: float): else: self.cursor_blink_delay_after_moving_acc += time_delta + def _calculate_text_space_pos(self, scaled_mouse_pos): + text_layout_top_left = self.get_text_layout_top_left() + height_adjustment = 0 + if self.scroll_bar is not None: + height_adjustment = (self.scroll_bar.start_percentage * self.text_box_layout.layout_rect.height) + text_layout_space_pos = (scaled_mouse_pos[0] - text_layout_top_left[0], + scaled_mouse_pos[1] - text_layout_top_left[1] + height_adjustment) + return text_layout_space_pos + def process_event(self, event: Event) -> bool: """ Allows the text entry box to react to input events, which is it's primary function. @@ -271,6 +290,24 @@ def _process_edit_pos_move_key(self, event: Event) -> bool: self.edit_position += 1 self.cursor_has_moved_recently = True consumed_event = True + elif event.key == K_UP: + if abs(self.select_range[0] - self.select_range[1]) > 0: + self.edit_position = self.text_box_layout.get_cursor_pos_move_up_one_row() + self.select_range = [0, 0] + self.cursor_has_moved_recently = True + else: + self.edit_position = self.text_box_layout.get_cursor_pos_move_up_one_row() + self.cursor_has_moved_recently = True + consumed_event = True + elif event.key == K_DOWN: + if abs(self.select_range[0] - self.select_range[1]) > 0: + self.edit_position = self.text_box_layout.get_cursor_pos_move_down_one_row() + self.select_range = [0, 0] + self.cursor_has_moved_recently = True + else: + self.edit_position = self.text_box_layout.get_cursor_pos_move_down_one_row() + self.cursor_has_moved_recently = True + consumed_event = True elif event.key == K_HOME: if abs(self.select_range[0] - self.select_range[1]) > 0: self.select_range = [0, 0] @@ -299,10 +336,7 @@ def _process_mouse_button_event(self, event: Event) -> bool: scaled_mouse_pos = self.ui_manager.calculate_scaled_mouse_position(event.pos) if self.hover_point(scaled_mouse_pos[0], scaled_mouse_pos[1]): if self.is_enabled: - text_layout_top_left = self.get_text_layout_top_left() - # TODO: need to account for scroll bar scrolling here - see example code - text_layout_space_pos = (scaled_mouse_pos[0] - text_layout_top_left[0], - scaled_mouse_pos[1] - text_layout_top_left[1]) + text_layout_space_pos = self._calculate_text_space_pos(scaled_mouse_pos) self.text_box_layout.set_cursor_from_click_pos(text_layout_space_pos) self.edit_position = self.text_box_layout.get_cursor_index() self.redraw_from_text_block() @@ -327,10 +361,7 @@ def _process_mouse_button_event(self, event: Event) -> bool: if self.hover_point(scaled_mouse_pos[0], scaled_mouse_pos[1]): consumed_event = True - text_layout_top_left = self.get_text_layout_top_left() - # TODO: need to account for scroll bar scrolling here - see example code - text_layout_space_pos = (scaled_mouse_pos[0] - text_layout_top_left[0], - scaled_mouse_pos[1] - text_layout_top_left[1]) + text_layout_space_pos = self._calculate_text_space_pos(scaled_mouse_pos) self.text_box_layout.set_cursor_from_click_pos(text_layout_space_pos) new_edit_pos = self.text_box_layout.get_cursor_index() if new_edit_pos != self.edit_position: @@ -383,3 +414,7 @@ def _calculate_double_click_word_selection(self): return True else: return False + + def redraw_from_text_block(self): + self.text_box_layout.fit_layout_rect_height_to_rows() + super().redraw_from_text_block() diff --git a/pygame_gui/elements/ui_vertical_scroll_bar.py b/pygame_gui/elements/ui_vertical_scroll_bar.py index 49327613..7e47729f 100644 --- a/pygame_gui/elements/ui_vertical_scroll_bar.py +++ b/pygame_gui/elements/ui_vertical_scroll_bar.py @@ -365,7 +365,7 @@ def redraw_scrollbar(self): scroll_bar_height = max(5, int(self.scrollable_height * self.visible_percentage)) x_pos = 0 - y_pos = (self.scroll_position + self.arrow_button_height) + y_pos = min((self.bottom_limit + self.arrow_button_height) - scroll_bar_height, (self.scroll_position + self.arrow_button_height)) self.sliding_rect_position = pygame.math.Vector2(x_pos, y_pos) self.sliding_button.set_relative_position(self.sliding_rect_position) From fdac10f2ff5a8750b618447abc624aed22ab7eb6 Mon Sep 17 00:00:00 2001 From: Dan Lawrence Date: Thu, 27 Oct 2022 18:41:55 +0100 Subject: [PATCH 04/14] Adding line break support to text entry box some minor bug fixes --- .../core/text/line_break_layout_rect.py | 13 ++- pygame_gui/core/text/text_box_layout.py | 103 +++++++++++++----- pygame_gui/core/text/text_box_layout_row.py | 50 +++++---- pygame_gui/elements/ui_text_entry_box.py | 20 +++- 4 files changed, 132 insertions(+), 54 deletions(-) diff --git a/pygame_gui/core/text/line_break_layout_rect.py b/pygame_gui/core/text/line_break_layout_rect.py index 4b7e12eb..2d96c17a 100644 --- a/pygame_gui/core/text/line_break_layout_rect.py +++ b/pygame_gui/core/text/line_break_layout_rect.py @@ -1,7 +1,9 @@ from typing import Tuple, Optional +import pygame from pygame.surface import Surface from pygame.rect import Rect +from pygame import Color from pygame_gui.core.text.text_layout_rect import TextLayoutRect @@ -15,6 +17,11 @@ class LineBreakLayoutRect(TextLayoutRect): """ def __init__(self, dimensions: Tuple[int, int]): super().__init__(dimensions) + self.letter_count = 1 + self.is_selected = False + self.selection_colour = Color(128, 128, 128, 255) + self.selection_chunk_width = 4 + self.select_surf = None def finalise(self, target_surface: Surface, @@ -24,4 +31,8 @@ def finalise(self, row_bg_height: int, x_scroll_offset: int = 0, letter_end: Optional[int] = None): - pass + if self.is_selected: + self.select_surf = Surface((self.selection_chunk_width, row_bg_height), flags=pygame.SRCALPHA) + self.select_surf.fill(self.selection_colour) + target_surface.blit(self.select_surf, self.topleft, special_flags=pygame.BLEND_PREMULTIPLIED) + diff --git a/pygame_gui/core/text/text_box_layout.py b/pygame_gui/core/text/text_box_layout.py index 84866c8a..73bb8c65 100644 --- a/pygame_gui/core/text/text_box_layout.py +++ b/pygame_gui/core/text/text_box_layout.py @@ -68,6 +68,7 @@ def __init__(self, self.edit_buffer = 2 self.cursor_text_row = None + self.last_horiz_cursor_row_pos = 0 self.selection_colour = pygame.Color(128, 128, 200, 255) self.selected_chunks = [] @@ -543,13 +544,15 @@ def set_cursor_position(self, cursor_pos): self.cursor_text_row.toggle_cursor() self.cursor_text_row = None - letter_acc = 0 + # we figure out how many edit positions there are in the text + # each letter counts as one additional position, but so does the start of a new row + edit_pos_accumulator = 0 for row in self.layout_rows: - if cursor_pos < letter_acc + row.letter_count: - row.set_cursor_position(cursor_pos - letter_acc) + if cursor_pos < edit_pos_accumulator + row.letter_count: + row.set_cursor_position(cursor_pos - edit_pos_accumulator) self.cursor_text_row = row break - elif cursor_pos == letter_acc + row.letter_count: + elif cursor_pos == edit_pos_accumulator + row.letter_count: # if the last character in a row is a space, we have more than one row and this isn't the last row # we want to jump to the start of the next row last_chunk = row.items[-1] @@ -557,18 +560,28 @@ def set_cursor_position(self, cursor_pos): if (len(last_chunk.text) > 0 and last_chunk.text[-1] == " " and row.row_index != (len(self.layout_rows) - 1)): - letter_acc += row.letter_count + edit_pos_accumulator += row.letter_count else: - row.set_cursor_position(cursor_pos - letter_acc) + row.set_cursor_position(cursor_pos - edit_pos_accumulator) self.cursor_text_row = row break + elif (len(self.layout_rows) > 1 and + isinstance(last_chunk, LineBreakLayoutRect) and + row.row_index != (len(self.layout_rows) - 1)): + # if the last chunk in a row is a line break and we have more than one row and this isn't the last row + # we want to jump to the start of the next row + edit_pos_accumulator += row.letter_count else: - row.set_cursor_position(cursor_pos - letter_acc) + row.set_cursor_position(cursor_pos - edit_pos_accumulator) self.cursor_text_row = row break else: - letter_acc += row.letter_count + edit_pos_accumulator += row.letter_count + + if edit_pos_accumulator == self.letter_count and len(self.layout_rows) > 0: + last_row = self.layout_rows[-1] + last_row.set_cursor_position(last_row.letter_count) def _find_cursor_row_from_click(self, click_pos): found_row = None @@ -610,7 +623,6 @@ def set_cursor_from_click_pos(self, click_pos): self.cursor_text_row = None found_row, final_click_pos = self._find_cursor_row_from_click(click_pos) - self.cursor_text_row = found_row if self.cursor_text_row is not None: self.cursor_text_row.set_cursor_from_click_pos(final_click_pos, len(self.layout_rows)) @@ -752,6 +764,11 @@ def _find_and_split_chunk(self, index: int, return_rhs: bool = False): break letter_accumulator += chunk.letter_count chunk_in_row_index += 1 + elif isinstance(chunk, LineBreakLayoutRect): + if index_in_row < letter_accumulator + chunk.letter_count: + letter_index = index_in_row - letter_accumulator + found_chunk = chunk + break if found_chunk is None: # couldn't find it on this row so use the first chunk of row below if row_index + 1 < len(self.layout_rows): @@ -763,8 +780,11 @@ def _find_and_split_chunk(self, index: int, return_rhs: bool = False): if isinstance(chunk, TextLineChunkFTFont): found_chunk = chunk break + elif isinstance(chunk, LineBreakLayoutRect): + found_chunk = chunk + break - if letter_index != 0: + if letter_index != 0 and found_chunk is not None and found_chunk.can_split(): # split the chunk # for the start chunk we want the right hand side of the split @@ -889,6 +909,7 @@ def delete_at_cursor(self): cursor_pos = self.cursor_text_row.cursor_index letter_acc = 0 deleted_character = False + chunk_to_remove = None for chunk in self.cursor_text_row.items: if isinstance(chunk, TextLineChunkFTFont): if cursor_pos <= letter_acc + (chunk.letter_count - 1): @@ -896,8 +917,16 @@ def delete_at_cursor(self): chunk.delete_letter_at_index(chunk_letter_pos) deleted_character = True break + elif isinstance(chunk, LineBreakLayoutRect): + # delete this chunk + if cursor_pos <= letter_acc + (chunk.letter_count - 1): + chunk_to_remove = chunk + deleted_character = True + break - letter_acc += chunk.letter_count + letter_acc += chunk.letter_count + if chunk_to_remove is not None: + current_row.items.remove(chunk_to_remove) if not deleted_character: # failed to delete character, must be at end of row - see if we have a row below # if so delete the first character of that row @@ -929,14 +958,30 @@ def backspace_at_cursor(self): current_row_index = current_row.row_index cursor_pos = self.cursor_text_row.cursor_index letter_acc = 0 - for chunk in self.cursor_text_row.items: - if cursor_pos <= letter_acc + chunk.letter_count: - chunk_letter_pos = cursor_pos - letter_acc - chunk.backspace_letter_at_index(chunk_letter_pos) - break + if current_row_index > 0 and cursor_pos == 0: + # at start of row with rows above + # need to delete from end of row above + current_row_index = current_row_index - 1 + current_row = self.layout_rows[current_row_index] + cursor_pos = current_row.letter_count + + chunk_to_remove = None + for chunk in current_row.items: + if isinstance(chunk, TextLineChunkFTFont): + if cursor_pos <= letter_acc + chunk.letter_count: + chunk_letter_pos = cursor_pos - letter_acc + chunk.backspace_letter_at_index(chunk_letter_pos) + break + elif isinstance(chunk, LineBreakLayoutRect): + # delete this chunk, line break has an edit pos length of 1 + if cursor_pos <= letter_acc + chunk.letter_count: + chunk_to_remove = chunk + break letter_acc += chunk.letter_count - self.cursor_text_row.set_cursor_position(cursor_pos - 1) + if chunk_to_remove is not None: + current_row.items.remove(chunk_to_remove) + current_row.set_cursor_position(cursor_pos - 1) temp_layout_queue = deque([]) for row_index in reversed(range(current_row_index, len(self.layout_rows))): self.layout_rows[row_index].rewind_row(temp_layout_queue) @@ -1052,9 +1097,9 @@ def get_cursor_y_pos(self): else: return 0, 0 - def get_cursor_pos_move_up_one_row(self): + def get_cursor_pos_move_up_one_row(self, last_cursor_horiz_index): """ - Returns a cursor character position in the row directly above the current cursor position + Returns a cursor character position in the row directly above the last horizontal cursor position if possible. """ if self.cursor_text_row is not None: @@ -1067,19 +1112,21 @@ def get_cursor_pos_move_up_one_row(self): row_above = self.layout_rows[i-1] cursor_index -= row_above.letter_count row_above_end = row_above.letter_count - if row_above.row_text_ends_with_a_space(): + if (row_above.row_text_ends_with_a_space() or + (len(row_above.items) > 0 and + isinstance(row_above.items[-1], LineBreakLayoutRect))): row_above_end = row_above.letter_count - 1 - cursor_index += min(self.cursor_text_row.get_cursor_index(), row_above_end) + cursor_index += min(last_cursor_horiz_index, row_above_end) break else: - cursor_index += self.cursor_text_row.get_cursor_index() + cursor_index += last_cursor_horiz_index break else: cursor_index += row.letter_count return cursor_index return 0 - def get_cursor_pos_move_down_one_row(self): + def get_cursor_pos_move_down_one_row(self, last_cursor_horiz_index): """ Returns a cursor character position in the row directly above the current cursor position if possible. @@ -1094,16 +1141,18 @@ def get_cursor_pos_move_down_one_row(self): row_below = self.layout_rows[i+1] cursor_index += row.letter_count row_below_end = row_below.letter_count - if row_below.row_text_ends_with_a_space(): + if (row_below.row_text_ends_with_a_space() or + (len(row_below.items) > 0 and + isinstance(row_below.items[-1], LineBreakLayoutRect))): row_below_end = row_below.letter_count - 1 - cursor_index += min(self.cursor_text_row.get_cursor_index(), row_below_end) + cursor_index += min(last_cursor_horiz_index, row_below_end) break else: - cursor_index += self.cursor_text_row.get_cursor_index() + cursor_index += last_cursor_horiz_index break else: cursor_index += row.letter_count - return cursor_index + return min(cursor_index, self.letter_count) return 0 diff --git a/pygame_gui/core/text/text_box_layout_row.py b/pygame_gui/core/text/text_box_layout_row.py index 29fe7f55..e39c85ad 100644 --- a/pygame_gui/core/text/text_box_layout_row.py +++ b/pygame_gui/core/text/text_box_layout_row.py @@ -6,6 +6,7 @@ from pygame_gui.core.text.text_line_chunk import TextLineChunkFTFont from pygame_gui.core.text.text_layout_rect import TextFloatPosition from pygame_gui.core.text.html_parser import HTMLParser +from pygame_gui.core.text.line_break_layout_rect import LineBreakLayoutRect class TextBoxLayoutRow(pygame.Rect): @@ -18,7 +19,7 @@ def __init__(self, row_start_x, row_start_y, row_index, line_spacing, layout): self.line_spacing = line_spacing self.row_index = row_index self.layout = layout - self.items: TextLayoutRect = [] + self.items: List[TextLayoutRect] = [] self.letter_count = 0 @@ -400,18 +401,16 @@ def find_cursor_pos_from_click_pos(self, click_pos: Tuple[int, int], num_rows: i for char_metric in chunk.font.get_metrics(chunk.text) if char_metric]) letter_acc += chunk.letter_count - if not found_chunk: - # not inside chunk + if (not found_chunk and scrolled_click_pos[0] >= self.right) or (letter_acc == self.letter_count): # if we have more than two rows check if we are on right of whole row and if row has space at the end. # If so stick the edit cursor before the space because this is how it works. - if num_rows > 1 and scrolled_click_pos[0] >= self.right: - last_chunk = self.items[-1] - if isinstance(last_chunk, TextLineChunkFTFont): - if last_chunk.text[-1] == " ": - letter_acc -= 1 - char_metric = last_chunk.font.get_metrics(" ")[0] - if char_metric: - cursor_draw_width -= char_metric[4] + if num_rows > 1 and self.row_text_ends_with_a_space(): + letter_acc -= 1 + last_chunk = self.get_last_text_chunk() + if last_chunk is not None: + char_metric = last_chunk.font.get_metrics(" ")[0] + if char_metric: + cursor_draw_width -= char_metric[4] cursor_index = min(self.letter_count, max(0, letter_acc)) return cursor_index, cursor_draw_width @@ -435,18 +434,21 @@ def set_cursor_position(self, cursor_pos): letter_acc = 0 cursor_draw_width = 0 for chunk in self.items: - if cursor_pos <= letter_acc + chunk.letter_count: - chunk_letter_pos = cursor_pos - letter_acc - cursor_draw_width += sum([char_metric[4] - for char_metric - in chunk.font.get_metrics(chunk.text[:chunk_letter_pos]) if char_metric]) + if isinstance(chunk, TextLineChunkFTFont): + if cursor_pos <= letter_acc + chunk.letter_count: + chunk_letter_pos = cursor_pos - letter_acc + cursor_draw_width += sum([char_metric[4] + for char_metric + in chunk.font.get_metrics(chunk.text[:chunk_letter_pos]) if char_metric]) - break + break - letter_acc += chunk.letter_count - cursor_draw_width += sum([char_metric[4] - for char_metric in - chunk.font.get_metrics(chunk.text) if char_metric]) + letter_acc += chunk.letter_count + cursor_draw_width += sum([char_metric[4] + for char_metric in + chunk.font.get_metrics(chunk.text) if char_metric]) + elif isinstance(chunk, LineBreakLayoutRect): + pass self.cursor_draw_width = cursor_draw_width @@ -491,3 +493,9 @@ def row_text_ends_with_a_space(self): if len(item.text) > 0 and item.text[-1] == " ": return True return False + + def get_last_text_chunk(self): + for item in reversed(self.items): + if isinstance(item, TextLineChunkFTFont): + return item + return None diff --git a/pygame_gui/elements/ui_text_entry_box.py b/pygame_gui/elements/ui_text_entry_box.py index 96ffdc35..6dc12be8 100644 --- a/pygame_gui/elements/ui_text_entry_box.py +++ b/pygame_gui/elements/ui_text_entry_box.py @@ -47,6 +47,8 @@ def __init__(self, self.cursor_on = False self.cursor_has_moved_recently = False + self.vertical_cursor_movement = False + self.last_horiz_cursor_index = 0 @property def select_range(self): @@ -102,7 +104,9 @@ def update(self, time_delta: float): return scaled_mouse_pos = self.ui_manager.get_mouse_position() if self.hover_point(scaled_mouse_pos[0], scaled_mouse_pos[1]): - if self.scroll_bar is not None and not self.scroll_bar.hover_point(scaled_mouse_pos[0], scaled_mouse_pos[1]): + if (self.scroll_bar is None or + (self.scroll_bar is not None and + not self.scroll_bar.hover_point(scaled_mouse_pos[0], scaled_mouse_pos[1]))): self.ui_manager.set_text_input_hovered(True) if self.double_click_timer < self.ui_manager.get_double_click_time(): @@ -123,6 +127,10 @@ def update(self, time_delta: float): self.cursor_blink_delay_after_moving_acc = 0.0 self.cursor_on = True self.text_box_layout.set_cursor_position(self.edit_position) + if not self.vertical_cursor_movement: + if self.text_box_layout.cursor_text_row is not None: + self.last_horiz_cursor_index = self.text_box_layout.cursor_text_row.get_cursor_index() + self.vertical_cursor_movement = False self.text_box_layout.turn_on_cursor() if self.scroll_bar is not None: cursor_y_pos_top, cursor_y_pos_bottom = self.text_box_layout.get_cursor_y_pos() @@ -292,21 +300,23 @@ def _process_edit_pos_move_key(self, event: Event) -> bool: consumed_event = True elif event.key == K_UP: if abs(self.select_range[0] - self.select_range[1]) > 0: - self.edit_position = self.text_box_layout.get_cursor_pos_move_up_one_row() + self.edit_position = self.text_box_layout.get_cursor_pos_move_up_one_row(self.last_horiz_cursor_index) self.select_range = [0, 0] self.cursor_has_moved_recently = True else: - self.edit_position = self.text_box_layout.get_cursor_pos_move_up_one_row() + self.edit_position = self.text_box_layout.get_cursor_pos_move_up_one_row(self.last_horiz_cursor_index) self.cursor_has_moved_recently = True + self.vertical_cursor_movement = True consumed_event = True elif event.key == K_DOWN: if abs(self.select_range[0] - self.select_range[1]) > 0: - self.edit_position = self.text_box_layout.get_cursor_pos_move_down_one_row() + self.edit_position = self.text_box_layout.get_cursor_pos_move_down_one_row(self.last_horiz_cursor_index) self.select_range = [0, 0] self.cursor_has_moved_recently = True else: - self.edit_position = self.text_box_layout.get_cursor_pos_move_down_one_row() + self.edit_position = self.text_box_layout.get_cursor_pos_move_down_one_row(self.last_horiz_cursor_index) self.cursor_has_moved_recently = True + self.vertical_cursor_movement = True consumed_event = True elif event.key == K_HOME: if abs(self.select_range[0] - self.select_range[1]) > 0: From e79921811cd3ba7d55dde1476dd4957b4e405584 Mon Sep 17 00:00:00 2001 From: Dan Lawrence Date: Thu, 27 Oct 2022 21:00:03 +0100 Subject: [PATCH 05/14] Adding basic unicode character entry --- pygame_gui/core/text/html_parser.py | 2 + pygame_gui/core/text/text_box_layout.py | 6 +++ pygame_gui/core/text/text_box_layout_row.py | 15 +++++-- pygame_gui/elements/ui_text_entry_box.py | 48 ++++++++++++++++++--- 4 files changed, 61 insertions(+), 10 deletions(-) diff --git a/pygame_gui/core/text/html_parser.py b/pygame_gui/core/text/html_parser.py index 71ece98d..565e842e 100644 --- a/pygame_gui/core/text/html_parser.py +++ b/pygame_gui/core/text/html_parser.py @@ -225,7 +225,9 @@ def _handle_line_break(self): dimensions = (current_font.get_rect(' ').width, int(round(self.current_style['font_size'] * self.line_spacing))) + chunk = self.create_styled_text_chunk('') self.layout_rect_queue.append(LineBreakLayoutRect(dimensions=dimensions)) + self.layout_rect_queue.append(chunk) def _handle_p_tag(self): if self.in_paragraph_block: diff --git a/pygame_gui/core/text/text_box_layout.py b/pygame_gui/core/text/text_box_layout.py index 73bb8c65..9ccc6c65 100644 --- a/pygame_gui/core/text/text_box_layout.py +++ b/pygame_gui/core/text/text_box_layout.py @@ -826,6 +826,12 @@ def insert_text(self, text: str, layout_index: int, parser: Optional[HTMLParser] """ current_row, index_in_row = self._find_row_from_text_box_index(layout_index) if current_row is not None: + if (index_in_row == current_row.letter_count and + current_row.row_index < (len(self.layout_rows) - 1) and + current_row.last_chunk_is_line_break()): + current_row = self.layout_rows[current_row.row_index + 1] + index_in_row = 0 + current_row.insert_text(text, index_in_row, parser) temp_layout_queue = deque([]) diff --git a/pygame_gui/core/text/text_box_layout_row.py b/pygame_gui/core/text/text_box_layout_row.py index e39c85ad..2ea16133 100644 --- a/pygame_gui/core/text/text_box_layout_row.py +++ b/pygame_gui/core/text/text_box_layout_row.py @@ -474,10 +474,11 @@ def insert_text(self, text: str, letter_row_index: int, letter_acc = 0 if len(self.items) > 0: for chunk in self.items: - if letter_row_index <= letter_acc + chunk.letter_count: - chunk_index = letter_row_index - letter_acc - chunk.insert_text(text, chunk_index) - break + if isinstance(chunk, TextLineChunkFTFont): + if letter_row_index <= letter_acc + chunk.letter_count: + chunk_index = letter_row_index - letter_acc + chunk.insert_text(text, chunk_index) + break letter_acc += chunk.letter_count elif parser is not None: @@ -499,3 +500,9 @@ def get_last_text_chunk(self): if isinstance(item, TextLineChunkFTFont): return item return None + + def last_chunk_is_line_break(self): + if len(self.items) > 0: + if isinstance(self.items[-1], LineBreakLayoutRect): + return True + return False diff --git a/pygame_gui/elements/ui_text_entry_box.py b/pygame_gui/elements/ui_text_entry_box.py index 6dc12be8..2c66a076 100644 --- a/pygame_gui/elements/ui_text_entry_box.py +++ b/pygame_gui/elements/ui_text_entry_box.py @@ -1,8 +1,8 @@ import re -from typing import Union, Tuple, Dict, Optional, Any +from typing import Union, Tuple, Dict, Optional from pygame import Rect, MOUSEBUTTONDOWN, MOUSEBUTTONUP, BUTTON_LEFT, KEYDOWN -from pygame import K_LEFT, K_RIGHT, K_UP, K_DOWN, K_HOME, K_END, K_BACKSPACE, K_DELETE +from pygame import K_LEFT, K_RIGHT, K_UP, K_DOWN, K_HOME, K_END, K_BACKSPACE, K_DELETE, K_RETURN from pygame import key from pygame.event import Event, post from pygame_gui._constants import UI_TEXT_ENTRY_CHANGED @@ -197,8 +197,8 @@ def process_event(self, event: Event) -> bool: # consumed_event = True if self._process_action_key_event(event): consumed_event = True - # elif self._process_text_entry_key(event): - # consumed_event = True + elif self._process_text_entry_key(event): + consumed_event = True if self.html_text != initial_text_state: # new event @@ -224,7 +224,6 @@ def _process_action_key_event(self, event: Event) -> bool: if event.key == K_BACKSPACE: if abs(self.select_range[0] - self.select_range[1]) > 0: self.text_box_layout.delete_selected_text() - self.redraw_from_text_block() low_end = min(self.select_range[0], self.select_range[1]) high_end = max(self.select_range[0], self.select_range[1]) self.html_text = self.html_text[:low_end] + self.html_text[high_end:] @@ -247,7 +246,6 @@ def _process_action_key_event(self, event: Event) -> bool: elif event.key == K_DELETE: if abs(self.select_range[0] - self.select_range[1]) > 0: self.text_box_layout.delete_selected_text() - self.redraw_from_text_block() low_end = min(self.select_range[0], self.select_range[1]) high_end = max(self.select_range[0], self.select_range[1]) self.html_text = self.html_text[:low_end] + self.html_text[high_end:] @@ -383,6 +381,44 @@ def _process_mouse_button_event(self, event: Event) -> bool: return consumed_event + def _process_text_entry_key(self, event: Event) -> bool: + """ + Process key input that can be added to the text entry text. + + :param event: The event to process. + + :return: True if consumed. + """ + consumed_event = False + + if hasattr(event, 'unicode'): + character = event.unicode + # here we really want to get the font metrics for the current active style where the edit cursor is + # instead we will make do for now + font = self.ui_theme.get_font(self.combined_element_ids) + char_metrics = font.get_metrics(character) + if len(char_metrics) > 0 and char_metrics[0] is not None: + valid_character = True + if valid_character: + if abs(self.select_range[0] - self.select_range[1]) > 0: + low_end = min(self.select_range[0], self.select_range[1]) + high_end = max(self.select_range[0], self.select_range[1]) + self.html_text = self.html_text[:low_end] + character + self.html_text[high_end:] + self.text_box_layout.delete_selected_text() + self.text_box_layout.insert_text(character, low_end, self.parser) + self.edit_position = low_end + 1 + self.select_range = [0, 0] + else: + start_str = self.html_text[:self.edit_position] + end_str = self.html_text[self.edit_position:] + self.html_text = start_str + character + end_str + self.text_box_layout.insert_text(character, self.edit_position, self.parser) + self.edit_position += 1 + self.redraw_from_text_block() + self.cursor_has_moved_recently = True + consumed_event = True + return consumed_event + def _calculate_double_click_word_selection(self): """ If we double clicked on a word in the text, select that word. From 5f11627c5f085ef183cc5fca2bb4740cea286338 Mon Sep 17 00:00:00 2001 From: Dan Lawrence Date: Fri, 28 Oct 2022 20:16:50 +0100 Subject: [PATCH 06/14] Squash a variety of bugs in text entry box --- .../core/drawable_shapes/drawable_shape.py | 3 +- pygame_gui/core/text/html_parser.py | 2 +- .../core/text/line_break_layout_rect.py | 6 +- .../core/text/simple_test_layout_rect.py | 2 +- pygame_gui/core/text/text_box_layout.py | 234 +++++++++++++----- pygame_gui/core/text/text_box_layout_row.py | 106 +++++--- pygame_gui/core/text/text_layout_rect.py | 5 +- pygame_gui/core/text/text_line_chunk.py | 34 ++- pygame_gui/elements/ui_text_box.py | 80 +++--- pygame_gui/elements/ui_text_entry_box.py | 13 +- pygame_gui/elements/ui_vertical_scroll_bar.py | 17 +- 11 files changed, 347 insertions(+), 155 deletions(-) diff --git a/pygame_gui/core/drawable_shapes/drawable_shape.py b/pygame_gui/core/drawable_shapes/drawable_shape.py index 8b82f820..a8a1c13b 100644 --- a/pygame_gui/core/drawable_shapes/drawable_shape.py +++ b/pygame_gui/core/drawable_shapes/drawable_shape.py @@ -517,7 +517,8 @@ def build_text_layout(self): text_actual_area_rect.height)) text_chunk.should_centre_from_baseline = True self.text_box_layout = TextBoxLayout(deque([text_chunk]), text_actual_area_rect, - self.text_view_rect, line_spacing=1.25) + self.text_view_rect, line_spacing=1.25, + default_font=self.theming['font']) if 'selected_bg' in self.theming: self.text_box_layout.selection_colour = self.theming['selected_bg'] if 'text_cursor_colour' in self.theming: diff --git a/pygame_gui/core/text/html_parser.py b/pygame_gui/core/text/html_parser.py index 565e842e..1a5b5c25 100644 --- a/pygame_gui/core/text/html_parser.py +++ b/pygame_gui/core/text/html_parser.py @@ -226,7 +226,7 @@ def _handle_line_break(self): int(round(self.current_style['font_size'] * self.line_spacing))) chunk = self.create_styled_text_chunk('') - self.layout_rect_queue.append(LineBreakLayoutRect(dimensions=dimensions)) + self.layout_rect_queue.append(LineBreakLayoutRect(dimensions=dimensions, font=current_font)) self.layout_rect_queue.append(chunk) def _handle_p_tag(self): diff --git a/pygame_gui/core/text/line_break_layout_rect.py b/pygame_gui/core/text/line_break_layout_rect.py index 2d96c17a..97d2eff4 100644 --- a/pygame_gui/core/text/line_break_layout_rect.py +++ b/pygame_gui/core/text/line_break_layout_rect.py @@ -15,13 +15,17 @@ class LineBreakLayoutRect(TextLayoutRect): :param dimensions: The dimensions of the 'line break', the height is the important thing so the new lines are spaced correctly for the last active font. """ - def __init__(self, dimensions: Tuple[int, int]): + def __init__(self, dimensions: Tuple[int, int], font): super().__init__(dimensions) self.letter_count = 1 self.is_selected = False self.selection_colour = Color(128, 128, 128, 255) self.selection_chunk_width = 4 self.select_surf = None + self.font = font + + def __repr__(self): + return "" def finalise(self, target_surface: Surface, diff --git a/pygame_gui/core/text/simple_test_layout_rect.py b/pygame_gui/core/text/simple_test_layout_rect.py index 05f2bd7d..13c3f0ca 100644 --- a/pygame_gui/core/text/simple_test_layout_rect.py +++ b/pygame_gui/core/text/simple_test_layout_rect.py @@ -56,7 +56,7 @@ def finalise(self, surface.fill(self.colour) target_surface.blit(surface, self, area=target_area) - def split(self, requested_x: int, line_width: int, row_start_x: int): + def split(self, requested_x: int, line_width: int, row_start_x: int, allow_split_dashes: bool): if line_width < self.smallest_split_size: raise ValueError('Line width is too narrow') diff --git a/pygame_gui/core/text/text_box_layout.py b/pygame_gui/core/text/text_box_layout.py index 9ccc6c65..8efd077c 100644 --- a/pygame_gui/core/text/text_box_layout.py +++ b/pygame_gui/core/text/text_box_layout.py @@ -31,11 +31,15 @@ def __init__(self, input_data_queue: Deque[TextLayoutRect], layout_rect: pygame.Rect, view_rect: pygame.Rect, - line_spacing: float): + line_spacing: float, + default_font, + allow_split_dashes: bool = True): # TODO: supply only a width and create final rect shape or just a final height? self.input_data_rect_queue = input_data_queue.copy() self.layout_rect = layout_rect.copy() self.line_spacing = line_spacing + self.default_font = default_font # this is the font used when we don't have anything else + self.allow_split_dashes = allow_split_dashes self.last_row_height = int(14 * self.line_spacing) self.view_rect = view_rect @@ -130,7 +134,8 @@ def _add_row_to_layout(self, current_row: TextBoxLayoutRow, last_row=False): # otherwise we add infinite rows with no height # instead add a line break rect to an empty row. if len(current_row.items) == 0 and not last_row: - current_row.add_item(LineBreakLayoutRect(dimensions=(2, self.last_row_height))) + current_row.add_item(LineBreakLayoutRect(dimensions=(2, self.last_row_height), + font=current_row.fall_back_font)) if current_row not in self.layout_rows: self.layout_rows.append(current_row) if current_row.bottom - self.layout_rect.y > self.layout_rect.height: @@ -224,36 +229,43 @@ def _add_floating_rect(self, current_row, test_layout_rect, input_queue): def _handle_span_rect(self, current_row, test_layout_rect): if not current_row.at_start(): # not at start of line so end current row... + prev_row_font = current_row.fall_back_font self._add_row_to_layout(current_row) # ...and start new one current_row = TextBoxLayoutRow(row_start_x=self.layout_rect.x, row_start_y=current_row.bottom, row_index=len(self.layout_rows), layout=self, line_spacing=self.line_spacing) + current_row = prev_row_font # Make the rect span the current row's full width & add it to the row test_layout_rect.width = self.layout_rect.width # TODO: floating rects? current_row.add_item(test_layout_rect) # add the row to the layout since it's now full up after spanning the full width... + prev_row_font = current_row.fall_back_font self._add_row_to_layout(current_row) # ...then start a new row current_row = TextBoxLayoutRow(row_start_x=self.layout_rect.x, row_start_y=current_row.bottom, row_index=len(self.layout_rows), layout=self, line_spacing=self.line_spacing) + current_row.fall_back_font = prev_row_font return current_row def _handle_line_break_rect(self, current_row, test_layout_rect): # line break, so first end current row... current_row.add_item(test_layout_rect) + prev_row_font = current_row.fall_back_font self._add_row_to_layout(current_row) # ...then start a new row - return TextBoxLayoutRow(row_start_x=self.layout_rect.x, - row_start_y=current_row.bottom, - row_index=len(self.layout_rows), - layout=self, line_spacing=self.line_spacing) + new_row = TextBoxLayoutRow(row_start_x=self.layout_rect.x, + row_start_y=current_row.bottom, + row_index=len(self.layout_rows), + layout=self, line_spacing=self.line_spacing) + new_row.fall_back_font = prev_row_font + return new_row def _split_rect_and_move_to_next_line(self, current_row, rhs_limit, test_layout_rect, @@ -267,7 +279,8 @@ def _split_rect_and_move_to_next_line(self, current_row, rhs_limit, try: new_layout_rect = test_layout_rect.split(split_point, self.layout_rect.width, - self.layout_rect.x) + self.layout_rect.x, + self.allow_split_dashes) if new_layout_rect is not None: # split successfully... @@ -290,19 +303,22 @@ def _split_rect_and_move_to_next_line(self, current_row, rhs_limit, input_queue.appendleft(test_layout_rect) # whether we split successfully or not, we need to end the current row... + prev_row_font = current_row.fall_back_font self._add_row_to_layout(current_row) # And then start a new one. if floater_height is not None: - return TextBoxLayoutRow(row_start_x=self.layout_rect.x, - row_start_y=floater_height, - row_index=len(self.layout_rows), - layout=self, line_spacing=self.line_spacing) + new_row = TextBoxLayoutRow(row_start_x=self.layout_rect.x, + row_start_y=floater_height, + row_index=len(self.layout_rows), + layout=self, line_spacing=self.line_spacing) else: - return TextBoxLayoutRow(row_start_x=self.layout_rect.x, - row_start_y=current_row.bottom, - row_index=len(self.layout_rows), - layout=self, line_spacing=self.line_spacing) + new_row = TextBoxLayoutRow(row_start_x=self.layout_rect.x, + row_start_y=current_row.bottom, + row_index=len(self.layout_rows), + layout=self, line_spacing=self.line_spacing) + new_row.fall_back_font = prev_row_font + return new_row def finalise_to_surf(self, surface: Surface): """ @@ -421,6 +437,8 @@ def insert_layout_rects(self, layout_rects: Deque[TextLayoutRect], Insert some new layout rectangles from a queue at specific place in the current layout. Hopefully this means we only need to redo the layout after this point... we shall see. + Warning: this is a test function, it may noit be up to date with current text layout features + :param layout_rects: the new TextLayoutRects to insert. :param row_index: which row we are sticking them on. :param item_index: which chunk we are sticking them into. @@ -555,26 +573,27 @@ def set_cursor_position(self, cursor_pos): elif cursor_pos == edit_pos_accumulator + row.letter_count: # if the last character in a row is a space, we have more than one row and this isn't the last row # we want to jump to the start of the next row - last_chunk = row.items[-1] - if len(self.layout_rows) > 1 and isinstance(last_chunk, TextLineChunkFTFont): - if (len(last_chunk.text) > 0 and - last_chunk.text[-1] == " " and - row.row_index != (len(self.layout_rows) - 1)): + if len(row.items) > 0: + last_chunk = row.items[-1] + if len(self.layout_rows) > 1 and isinstance(last_chunk, TextLineChunkFTFont): + if (len(last_chunk.text) > 0 and + last_chunk.text[-1] == " " and + row.row_index != (len(self.layout_rows) - 1)): + edit_pos_accumulator += row.letter_count + else: + row.set_cursor_position(cursor_pos - edit_pos_accumulator) + self.cursor_text_row = row + break + elif (len(self.layout_rows) > 1 and + isinstance(last_chunk, LineBreakLayoutRect) and + row.row_index != (len(self.layout_rows) - 1)): + # if the last chunk in a row is a line break and we have more than one row and this isn't the last row + # we want to jump to the start of the next row edit_pos_accumulator += row.letter_count else: row.set_cursor_position(cursor_pos - edit_pos_accumulator) self.cursor_text_row = row break - elif (len(self.layout_rows) > 1 and - isinstance(last_chunk, LineBreakLayoutRect) and - row.row_index != (len(self.layout_rows) - 1)): - # if the last chunk in a row is a line break and we have more than one row and this isn't the last row - # we want to jump to the start of the next row - edit_pos_accumulator += row.letter_count - else: - row.set_cursor_position(cursor_pos - edit_pos_accumulator) - self.cursor_text_row = row - break else: edit_pos_accumulator += row.letter_count @@ -728,20 +747,23 @@ def set_text_selection(self, start_index, end_index): self.selected_rows.append(row) rows_to_finalise.add(row) for chunk in row.items: - if chunk == start_chunk: - start_selection = True - - if start_selection and not end_selection: - if chunk == end_chunk and end_letter_index == 0: - # don't select this - pass - else: - chunk.is_selected = True - chunk.selection_colour = self.selection_colour - self.selected_chunks.append(chunk) - - if chunk == end_chunk: - end_selection = True + if chunk is not None: + if chunk == start_chunk: + start_selection = True + + if start_selection and not end_selection: + if chunk == end_chunk and end_letter_index == 0: + # don't select this + pass + else: + chunk.is_selected = True + chunk.selection_colour = self.selection_colour + self.selected_chunks.append(chunk) + + if chunk == end_chunk: + end_selection = True + else: + print("found None chunk in row: ", row.row_index, " with items: ", row.items) if self.finalised_surface is not None: for row in rows_to_finalise: @@ -770,6 +792,7 @@ def _find_and_split_chunk(self, index: int, return_rhs: bool = False): found_chunk = chunk break if found_chunk is None: + chunk_in_row_index = 0 # couldn't find it on this row so use the first chunk of row below if row_index + 1 < len(self.layout_rows): chunk_row = self.layout_rows[row_index+1] @@ -784,13 +807,20 @@ def _find_and_split_chunk(self, index: int, return_rhs: bool = False): found_chunk = chunk break - if letter_index != 0 and found_chunk is not None and found_chunk.can_split(): + if found_chunk is not None and found_chunk.can_split(): # split the chunk # for the start chunk we want the right hand side of the split new_chunk = found_chunk.split_index(letter_index) chunk_row.items.insert(chunk_in_row_index + 1, new_chunk) + first_half = found_chunk + second_half = new_chunk + if isinstance(first_half, TextLineChunkFTFont): + first_half = first_half.text + if isinstance(second_half, TextLineChunkFTFont): + second_half = second_half.text + if return_rhs: found_chunk = new_chunk @@ -834,20 +864,72 @@ def insert_text(self, text: str, layout_index: int, parser: Optional[HTMLParser] current_row.insert_text(text, index_in_row, parser) + row_to_process_from = current_row + row_to_process_from_index = current_row.row_index + if current_row.row_index > 0: + row_to_process_from = self.layout_rows[current_row.row_index - 1] + row_to_process_from_index = row_to_process_from.row_index + temp_layout_queue = deque([]) - for row in reversed(self.layout_rows[current_row.row_index:]): + for row in reversed(self.layout_rows[row_to_process_from_index:]): row.rewind_row(temp_layout_queue) - self.layout_rows = self.layout_rows[:current_row.row_index] + self.layout_rows = self.layout_rows[:row_to_process_from_index] self._merge_adjacent_compatible_chunks(temp_layout_queue) - self._process_layout_queue(temp_layout_queue, current_row) + self._process_layout_queue(temp_layout_queue, row_to_process_from) if self.finalised_surface is not None: - for row in self.layout_rows[current_row.row_index:]: + for row in self.layout_rows[row_to_process_from_index:]: row.finalise(self.finalised_surface) else: raise RuntimeError("no rows in text box layout") + def insert_line_break(self, layout_index: int, parser: Optional[HTMLParser]): + """ + Insert a line break into the text layout at a given point. + + :param layout_index: the character index at which to insert the line break. + :param parser: An optional HTML parser for text styling data + """ + found_chunk, index_in_row, row_index = self._find_and_split_chunk(layout_index) + + if len(self.layout_rows) > 0 and found_chunk is not None: + current_row = self.layout_rows[row_index] + current_row.insert_linebreak_after_chunk(found_chunk, parser) + + temp_layout_queue = deque([]) + for row in reversed(self.layout_rows[row_index:]): + row.rewind_row(temp_layout_queue) + + self.layout_rows = self.layout_rows[:row_index] + self._merge_adjacent_compatible_chunks(temp_layout_queue) + self._process_layout_queue(temp_layout_queue, current_row) + + if self.finalised_surface is not None: + for row in self.layout_rows[current_row.row_index:]: + row.finalise(self.finalised_surface) + if found_chunk is None: + if layout_index == self.letter_count: + if len(self.layout_rows) > 0: + last_row = self.layout_rows[-1] + last_chunk = last_row.get_last_text_or_line_break_chunk() + if last_chunk is not None: + last_row.insert_linebreak_after_chunk(last_chunk, parser) + + temp_layout_queue = deque([]) + for row in reversed(self.layout_rows[last_row.row_index:]): + row.rewind_row(temp_layout_queue) + + self.layout_rows = self.layout_rows[:last_row.row_index] + self._merge_adjacent_compatible_chunks(temp_layout_queue) + self._process_layout_queue(temp_layout_queue, last_row) + + if self.finalised_surface is not None: + for row in self.layout_rows[last_row.row_index:]: + row.finalise(self.finalised_surface) + else: + print("couldn't find chunk to split at layout index: ", layout_index) + def delete_selected_text(self): """ Delete the currently selected text. @@ -871,23 +953,28 @@ def delete_selected_text(self): current_row_index = current_row.row_index for row in reversed(self.selected_rows): row.items = [chunk for chunk in row.items if not chunk.is_selected] - # row.rewind_row(temp_layout_queue) if row.row_index > max_row_index: max_row_index = row.row_index - for row_index in reversed(range(current_row_index, len(self.layout_rows))): - self.layout_rows[row_index].rewind_row(temp_layout_queue) + row_to_process_from = current_row + row_to_process_from_index = current_row.row_index + if current_row.row_index > 0: + row_to_process_from = self.layout_rows[current_row.row_index - 1] + row_to_process_from_index = row_to_process_from.row_index + + temp_layout_queue = deque([]) + for row in reversed(self.layout_rows[row_to_process_from_index:]): + row.rewind_row(temp_layout_queue) - # clear out rows that may now be empty first - newly_empty_rows = self.layout_rows[current_row_index:] + newly_empty_rows = self.layout_rows[row_to_process_from_index:] if self.finalised_surface is not None: for row in newly_empty_rows: row.clear() - self.layout_rows = self.layout_rows[:current_row_index] + self.layout_rows = self.layout_rows[:row_to_process_from_index] self._merge_adjacent_compatible_chunks(temp_layout_queue) - self._process_layout_queue(temp_layout_queue, current_row) + self._process_layout_queue(temp_layout_queue, row_to_process_from) if len(current_row.items) == 0: current_row_starting_chunk.text = '' @@ -898,7 +985,7 @@ def delete_selected_text(self): current_row.add_item(current_row_starting_chunk) if self.finalised_surface is not None: - for row in self.layout_rows[current_row_index:]: + for row in self.layout_rows[row_to_process_from_index:]: row.finalise(self.finalised_surface) self.selected_rows = [] @@ -942,16 +1029,22 @@ def delete_at_cursor(self): if isinstance(chunk, TextLineChunkFTFont): chunk.delete_letter_at_index(0) + row_to_process_from = current_row + row_to_process_from_index = current_row.row_index + if current_row.row_index > 0: + row_to_process_from = self.layout_rows[current_row.row_index - 1] + row_to_process_from_index = row_to_process_from.row_index + temp_layout_queue = deque([]) - for row_index in reversed(range(current_row_index, len(self.layout_rows))): - self.layout_rows[row_index].rewind_row(temp_layout_queue) + for row in reversed(self.layout_rows[row_to_process_from_index:]): + row.rewind_row(temp_layout_queue) - self.layout_rows = self.layout_rows[:current_row_index] + self.layout_rows = self.layout_rows[:row_to_process_from_index] self._merge_adjacent_compatible_chunks(temp_layout_queue) - self._process_layout_queue(temp_layout_queue, current_row) + self._process_layout_queue(temp_layout_queue, row_to_process_from) if self.finalised_surface is not None: - for row in self.layout_rows[current_row_index:]: + for row in self.layout_rows[row_to_process_from_index:]: row.finalise(self.finalised_surface) def backspace_at_cursor(self): @@ -988,16 +1081,23 @@ def backspace_at_cursor(self): if chunk_to_remove is not None: current_row.items.remove(chunk_to_remove) current_row.set_cursor_position(cursor_pos - 1) + + row_to_process_from = current_row + row_to_process_from_index = current_row.row_index + if current_row.row_index > 0: + row_to_process_from = self.layout_rows[current_row.row_index - 1] + row_to_process_from_index = row_to_process_from.row_index + temp_layout_queue = deque([]) - for row_index in reversed(range(current_row_index, len(self.layout_rows))): - self.layout_rows[row_index].rewind_row(temp_layout_queue) + for row in reversed(self.layout_rows[row_to_process_from_index:]): + row.rewind_row(temp_layout_queue) - self.layout_rows = self.layout_rows[:current_row_index] + self.layout_rows = self.layout_rows[:row_to_process_from_index] self._merge_adjacent_compatible_chunks(temp_layout_queue) - self._process_layout_queue(temp_layout_queue, current_row) + self._process_layout_queue(temp_layout_queue, row_to_process_from) if self.finalised_surface is not None: - for row in self.layout_rows[current_row_index:]: + for row in self.layout_rows[row_to_process_from_index:]: row.finalise(self.finalised_surface) def _find_row_from_text_box_index(self, text_box_index: int): diff --git a/pygame_gui/core/text/text_box_layout_row.py b/pygame_gui/core/text/text_box_layout_row.py index 2ea16133..62ca6f0a 100644 --- a/pygame_gui/core/text/text_box_layout_row.py +++ b/pygame_gui/core/text/text_box_layout_row.py @@ -1,6 +1,7 @@ -from typing import Optional, Tuple, List +from typing import Optional, Tuple, List, Union import math import pygame +import pygame.freetype from pygame_gui.core.text.text_layout_rect import TextLayoutRect from pygame_gui.core.text.text_line_chunk import TextLineChunkFTFont @@ -33,6 +34,10 @@ def __init__(self, row_start_x, row_start_y, row_index, line_spacing, layout): self.edit_right_margin = 2 self.cursor_index = 0 self.cursor_draw_width = 0 + # if we add an empty row and then start adding text, what font do we use? + # this one. + self.fall_back_font = self.layout.default_font + self.surf_row_dirty = False def __hash__(self): return self.row_index @@ -78,6 +83,8 @@ def add_item(self, item: TextLayoutRect): origin_item.origin_row_y_adjust = self.y_origin - origin_item.y_origin origin_item.top = origin_item.pre_row_rect.top + origin_item.origin_row_y_adjust + self.fall_back_font = item.font + self.letter_count += item.letter_count def rewind_row(self, layout_rect_queue): @@ -222,7 +229,7 @@ def merge_adjacent_compatible_chunks(self): else: index += 1 - def finalise(self, surface, current_end_pos: Optional[int] = None, + def finalise(self, surface: pygame.Surface, current_end_pos: Optional[int] = None, cumulative_letter_count: Optional[int] = None): """ Finalise this row, turning it into pixels on a pygame surface. Generally done once we are @@ -235,45 +242,53 @@ def finalise(self, surface, current_end_pos: Optional[int] = None, Also helps with the 'typewriter' effect. """ self.merge_adjacent_compatible_chunks() - for text_chunk in self.items: - chunk_view_rect = pygame.Rect(self.layout.layout_rect.left, - 0, self.layout.view_rect.width, - self.layout.view_rect.height) - if isinstance(text_chunk, TextLineChunkFTFont): - if current_end_pos is not None and cumulative_letter_count is not None: - if cumulative_letter_count < current_end_pos: + if surface == self.layout.finalised_surface and self.layout.layout_rect.height > surface.get_height(): + self.layout.finalise_to_new() + else: + if self.surf_row_dirty: + self.clear() + for text_chunk in self.items: + if text_chunk is not None: + chunk_view_rect = pygame.Rect(self.layout.layout_rect.left, + 0, self.layout.view_rect.width, + self.layout.view_rect.height) + if isinstance(text_chunk, TextLineChunkFTFont): + if current_end_pos is not None and cumulative_letter_count is not None: + if cumulative_letter_count < current_end_pos: + text_chunk.finalise(surface, chunk_view_rect, self.y_origin, + self.text_chunk_height, self.height, + self.layout.x_scroll_offset, + current_end_pos - cumulative_letter_count) + cumulative_letter_count += text_chunk.letter_count + else: + text_chunk.finalise(surface, chunk_view_rect, self.y_origin, + self.text_chunk_height, self.height, + self.layout.x_scroll_offset) + else: text_chunk.finalise(surface, chunk_view_rect, self.y_origin, self.text_chunk_height, self.height, - self.layout.x_scroll_offset, - current_end_pos - cumulative_letter_count) - cumulative_letter_count += text_chunk.letter_count + self.layout.x_scroll_offset) else: - text_chunk.finalise(surface, chunk_view_rect, self.y_origin, - self.text_chunk_height, self.height, - self.layout.x_scroll_offset) - else: - text_chunk.finalise(surface, chunk_view_rect, self.y_origin, - self.text_chunk_height, self.height, - self.layout.x_scroll_offset) + print(self.items) - if self.edit_cursor_active: - cursor_surface = pygame.surface.Surface(self.cursor_rect.size, - flags=pygame.SRCALPHA, depth=32) + if self.edit_cursor_active: + cursor_surface = pygame.surface.Surface(self.cursor_rect.size, + flags=pygame.SRCALPHA, depth=32) - cursor_surface.fill(self.layout.get_cursor_colour()) - self.cursor_rect = pygame.Rect((self.x + - self.cursor_draw_width - - self.layout.x_scroll_offset), - self.y, - 2, - max(0, self.height - 2)) - surface.blit(cursor_surface, self.cursor_rect, special_flags=pygame.BLEND_PREMULTIPLIED) + cursor_surface.fill(self.layout.get_cursor_colour()) + self.cursor_rect = pygame.Rect((self.x + + self.cursor_draw_width - + self.layout.x_scroll_offset), + self.y, + 2, + max(0, self.height - 2)) + surface.blit(cursor_surface, self.cursor_rect, special_flags=pygame.BLEND_PREMULTIPLIED) - self.target_surface = surface + self.target_surface = surface # pygame.draw.rect(self.target_surface, pygame.Color('#FF0000'), self.layout.layout_rect, 1) # pygame.draw.rect(self.target_surface, pygame.Color('#00FF00'), self, 1) - + self.surf_row_dirty = True return cumulative_letter_count def set_default_text_colour(self, colour): @@ -334,16 +349,17 @@ def turn_on_cursor(self): def clear(self): """ - 'Clears' the current row from it's target surface by setting the + 'Clears' the current row from its target surface by setting the area taken up by this row to transparent black. Hopefully the target surface is supposed to be transparent black when empty. """ - if self.target_surface is not None: + if self.target_surface is not None and self.surf_row_dirty: slightly_wider_rect = pygame.Rect(self.x, self.y, self.layout.view_rect.width, self.height) self.target_surface.fill(pygame.Color('#00000000'), slightly_wider_rect) + self.surf_row_dirty = False def _setup_offset_position_from_edit_cursor(self): if self.cursor_draw_width > (self.layout.x_scroll_offset + @@ -488,6 +504,22 @@ def insert_text(self, text: str, letter_row_index: int, raise AttributeError("Trying to insert into empty text row with no Parser" " for style data - fix this later?") + def insert_linebreak_after_chunk(self, chunk_to_insert_after: Union[TextLineChunkFTFont, LineBreakLayoutRect], parser: HTMLParser): + if len(self.items) > 0: + chunk_insert_index = 0 + for chunk_index, chunk in enumerate(self.items): + if chunk == chunk_to_insert_after: + chunk_insert_index = chunk_index + 1 + break + current_font: pygame.freetype.Font = chunk_to_insert_after.font + current_font_size = current_font.size + dimensions = (current_font.get_rect(' ').width, + int(round(current_font_size * + self.line_spacing))) + self.items.insert(chunk_insert_index, LineBreakLayoutRect(dimensions, current_font)) + empty_text_chunk = parser.create_styled_text_chunk('') + self.items.insert(chunk_insert_index + 1, empty_text_chunk) + def row_text_ends_with_a_space(self): for item in reversed(self.items): if isinstance(item, TextLineChunkFTFont): @@ -501,6 +533,12 @@ def get_last_text_chunk(self): return item return None + def get_last_text_or_line_break_chunk(self): + for item in reversed(self.items): + if isinstance(item, TextLineChunkFTFont) or isinstance(item, LineBreakLayoutRect): + return item + return None + def last_chunk_is_line_break(self): if len(self.items) > 0: if isinstance(self.items[-1], LineBreakLayoutRect): diff --git a/pygame_gui/core/text/text_layout_rect.py b/pygame_gui/core/text/text_layout_rect.py index 6009a6c1..dd1c0b89 100644 --- a/pygame_gui/core/text/text_layout_rect.py +++ b/pygame_gui/core/text/text_layout_rect.py @@ -106,7 +106,8 @@ def float_pos(self) -> TextFloatPosition: def split(self, requested_x: int, line_width: int, - row_start_x: int) -> Union['TextLayoutRect', None]: # noqa + row_start_x: int, + allow_split_dashes: bool) -> Union['TextLayoutRect', None]: # noqa """ Try to perform a split operation on this rectangle. Often rectangles will be split at the nearest point that is still less than the request (i.e. to the left of the request in @@ -115,6 +116,8 @@ def split(self, :param requested_x: the requested place to split this rectangle along it's width. :param line_width: the width of the current line. :param row_start_x: the x start position of the row. + :param allow_split_dashes: whether we allow text to be split with dashes either side. + allowing this makes direct text editing more annoying. """ if line_width < self.smallest_split_size: diff --git a/pygame_gui/core/text/text_line_chunk.py b/pygame_gui/core/text/text_line_chunk.py index edb1e67f..29a9b887 100644 --- a/pygame_gui/core/text/text_line_chunk.py +++ b/pygame_gui/core/text/text_line_chunk.py @@ -95,6 +95,9 @@ def __init__(self, text: str, self.effects_offset_pos = (0, 0) self.transform_effect_rect = pygame.Rect(self.topleft, self.size) + def __repr__(self): + return "< '" + self.text + "' " + super().__repr__() + " >" + def _handle_dimensions(self, font, max_dimensions, text): if len(text) == 0: text_rect = font.get_rect('A') @@ -373,7 +376,8 @@ def _apply_shadow_effect(self, surface, text_rect, text_str, text_surface, origi def split(self, requested_x: int, line_width: int, - row_start_x: int) -> Union['TextLayoutRect', None]: + row_start_x: int, + allow_split_dashes: bool) -> Union['TextLayoutRect', None]: """ Try to perform a split operation on this chunk at the requested pixel position. @@ -381,9 +385,11 @@ def split(self, the request (i.e. to the left of the request in the common left-to-right text layout case) . - :param requested_x: the requested place to split this rectangle along it's width. + :param requested_x: the requested place to split this rectangle along its width. :param line_width: the width of the current line. :param row_start_x: the x start position of the row. + :param allow_split_dashes: whether we allow text to be split with dashes either side. + allowing this makes direct text editing more annoying. """ # starting heuristic: find the percentage through the chunk width of this split request percentage_split = 0 @@ -405,13 +411,18 @@ def split(self, # we have a chunk with no breaks (one long word usually) longer than a line # split it at the word optimum_split_point = max(1, int(percentage_split * len(self.text)) - 1) - if optimum_split_point != 1: - # have to be at least wide enough to fit in a dash and another character - left_side = self.text[:optimum_split_point] + '-' - right_side = '-' + self.text[optimum_split_point:] - split_text_ok = True + if allow_split_dashes: + if optimum_split_point != 1: + # have to be at least wide enough to fit in a dash and another character + left_side = self.text[:optimum_split_point] + '-' + right_side = '-' + self.text[optimum_split_point:] + split_text_ok = True + else: + raise ValueError('Line width is too narrow') else: - raise ValueError('Line width is too narrow') + left_side = self.text[:optimum_split_point] + right_side = self.text[optimum_split_point:] + split_text_ok = True if split_text_ok: # update the data for this chunk @@ -473,7 +484,7 @@ def split_index(self, index): :param index: the requested index at which to split this rectangle along it's width. """ - if 0 < index < len(self.text): + if 0 <= index < len(self.text): left_side = self.text[:index] right_side = self.text[index:] @@ -486,7 +497,12 @@ def split_index(self, index): return self._split_at(right_side, self.topright, self.target_surface, self.target_surface_area, self.should_centre_from_baseline) + elif index == 0 and len(self.text) == 0: + # special case + return self._split_at("", self.topright, self.target_surface, + self.target_surface_area, self.should_centre_from_baseline) else: + print("index is bad at: ", index, "len(self.text): ", len(self.text)) return None def _split_at(self, right_side, split_pos, target_surface, diff --git a/pygame_gui/elements/ui_text_box.py b/pygame_gui/elements/ui_text_box.py index 95f4159c..c761a9f1 100644 --- a/pygame_gui/elements/ui_text_box.py +++ b/pygame_gui/elements/ui_text_box.py @@ -86,7 +86,8 @@ def __init__(self, visible: int = 1, *, pre_parsing_enabled: bool = True, - text_kwargs: Optional[Dict[str, str]] = None): + text_kwargs: Optional[Dict[str, str]] = None, + allow_split_dashes: bool = True): super().__init__(relative_rect, manager, container, starting_height=layer_starting_height, @@ -106,6 +107,7 @@ def __init__(self, if text_kwargs is not None: self.text_kwargs = text_kwargs self.font_dict = self.ui_theme.get_font_dictionary() + self.allow_split_dashes = allow_split_dashes self._pre_parsing_enabled = pre_parsing_enabled @@ -541,7 +543,9 @@ def parse_html_into_style_data(self): self.text_wrap_rect[3])), pygame.Rect((0, 0), (self.text_wrap_rect[2], self.text_wrap_rect[3])), - line_spacing=1.25) + line_spacing=1.25, + default_font=self.ui_theme.get_font_dictionary().get_default_font(), + allow_split_dashes=self.allow_split_dashes) self.parser.empty_layout_queue() if self.text_wrap_rect[3] == -1: self.text_box_layout.view_rect.height = self.text_box_layout.layout_rect.height @@ -557,41 +561,45 @@ def redraw_from_text_block(self): """ if self.rect.width <= 0 or self.rect.height <= 0: return - if self.scroll_bar is not None: - height_adjustment = int(self.scroll_bar.start_percentage * - self.text_box_layout.layout_rect.height) - percentage_visible = (self.text_wrap_rect[3] / - self.text_box_layout.layout_rect.height) - if percentage_visible >= 1.0: - self.scroll_bar.kill() - self.scroll_bar = None - height_adjustment = 0 - else: - self.scroll_bar.set_visible_percentage(percentage_visible) + if (self.scroll_bar is None and + (self.text_box_layout.layout_rect.height > self.text_wrap_rect[3])): + self.rebuild() else: - height_adjustment = 0 - drawable_area_size = (max(1, (self.rect[2] - - (self.padding[0] * 2) - - (self.border_width * 2) - - (self.shadow_width * 2) - - (2 * self.rounded_corner_offset))), - max(1, (self.rect[3] - - (self.padding[1] * 2) - - (self.border_width * 2) - - (self.shadow_width * 2) - - (2 * self.rounded_corner_offset)))) - drawable_area = pygame.Rect((0, height_adjustment), - drawable_area_size) - new_image = pygame.surface.Surface(self.rect.size, flags=pygame.SRCALPHA, depth=32) - new_image.fill(pygame.Color(0, 0, 0, 0)) - basic_blit(new_image, self.background_surf, (0, 0)) - basic_blit(new_image, self.text_box_layout.finalised_surface, - (self.padding[0] + self.border_width + - self.shadow_width + self.rounded_corner_offset, - self.padding[1] + self.border_width + - self.shadow_width + self.rounded_corner_offset), - drawable_area) - self._set_image(new_image) + if self.scroll_bar is not None: + height_adjustment = int(self.scroll_bar.start_percentage * + self.text_box_layout.layout_rect.height) + percentage_visible = (self.text_wrap_rect[3] / + self.text_box_layout.layout_rect.height) + if percentage_visible >= 1.0: + self.scroll_bar.kill() + self.scroll_bar = None + height_adjustment = 0 + else: + self.scroll_bar.set_visible_percentage(percentage_visible) + else: + height_adjustment = 0 + drawable_area_size = (max(1, (self.rect[2] - + (self.padding[0] * 2) - + (self.border_width * 2) - + (self.shadow_width * 2) - + (2 * self.rounded_corner_offset))), + max(1, (self.rect[3] - + (self.padding[1] * 2) - + (self.border_width * 2) - + (self.shadow_width * 2) - + (2 * self.rounded_corner_offset)))) + drawable_area = pygame.Rect((0, height_adjustment), + drawable_area_size) + new_image = pygame.surface.Surface(self.rect.size, flags=pygame.SRCALPHA, depth=32) + new_image.fill(pygame.Color(0, 0, 0, 0)) + basic_blit(new_image, self.background_surf, (0, 0)) + basic_blit(new_image, self.text_box_layout.finalised_surface, + (self.padding[0] + self.border_width + + self.shadow_width + self.rounded_corner_offset, + self.padding[1] + self.border_width + + self.shadow_width + self.rounded_corner_offset), + drawable_area) + self._set_image(new_image) def redraw_from_chunks(self): """ diff --git a/pygame_gui/elements/ui_text_entry_box.py b/pygame_gui/elements/ui_text_entry_box.py index 2c66a076..55e9a649 100644 --- a/pygame_gui/elements/ui_text_entry_box.py +++ b/pygame_gui/elements/ui_text_entry_box.py @@ -29,7 +29,8 @@ def __init__(self, parent_element=parent_element, object_id=object_id, anchors=anchors, - visible=visible) + visible=visible, + allow_split_dashes=False) # input timings - I expect nobody really wants to mess with these that much # ideally we could populate from the os settings but that sounds like a headache @@ -390,8 +391,16 @@ def _process_text_entry_key(self, event: Event) -> bool: :return: True if consumed. """ consumed_event = False + if event.key == K_RETURN: + start_str = self.html_text[:self.edit_position] + end_str = self.html_text[self.edit_position:] + self.html_text = start_str + '\n' + end_str + self.text_box_layout.insert_line_break(self.edit_position, self.parser) + self.edit_position += 1 + self.redraw_from_text_block() + self.cursor_has_moved_recently = True - if hasattr(event, 'unicode'): + elif hasattr(event, 'unicode'): character = event.unicode # here we really want to get the font metrics for the current active style where the edit cursor is # instead we will make do for now diff --git a/pygame_gui/elements/ui_vertical_scroll_bar.py b/pygame_gui/elements/ui_vertical_scroll_bar.py index 7e47729f..c11c96b0 100644 --- a/pygame_gui/elements/ui_vertical_scroll_bar.py +++ b/pygame_gui/elements/ui_vertical_scroll_bar.py @@ -56,7 +56,7 @@ def __init__(self, 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._start_percentage = 0.0 self.grabbed_slider = False self.has_moved_recently = False @@ -107,6 +107,18 @@ def __init__(self, self.join_focus_sets(self.sliding_button) self.sliding_button.set_hold_range((100, self.background_rect.height)) + @property + def start_percentage(self): + """ + turning start_percentage into a property so we can round it to mitigate floating point errors + """ + return self._start_percentage + + @start_percentage.setter + def start_percentage(self, value): + rounded_value = round(value, 4) + self._start_percentage = rounded_value + def rebuild(self): """ Rebuild anything that might need rebuilding. @@ -330,7 +342,8 @@ def update(self, time_delta: float): self.grabbed_slider = False if moved_this_frame: - self.start_percentage = self.scroll_position / self.scrollable_height + self.start_percentage = min(self.scroll_position / self.scrollable_height, + 1.0 - self.visible_percentage) if not self.has_moved_recently: self.has_moved_recently = True From 68a405b590e2747ddad16a1e13823fc565128e2d Mon Sep 17 00:00:00 2001 From: Dan Lawrence Date: Sat, 29 Oct 2022 10:47:33 +0100 Subject: [PATCH 07/14] Ensure layering is working correctly for dropdowns and window stacks Now always placing new windows one layer above the highest layer below. Should ensure there are no overlaps. Could use more testing. drop down was also incorrectly registering a thickness of 3 because the expanded selection list was not being properly included as it is outside of drop down bounds/container. Have hardcoded it to 4. --- pygame_gui/core/ui_element.py | 1 - pygame_gui/core/ui_window_stack.py | 5 +++-- pygame_gui/elements/ui_drop_down_menu.py | 10 ++++++++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/pygame_gui/core/ui_element.py b/pygame_gui/core/ui_element.py index 88177b98..8b4a5f48 100644 --- a/pygame_gui/core/ui_element.py +++ b/pygame_gui/core/ui_element.py @@ -723,7 +723,6 @@ def check_hover(self, time_delta: float, hovered_higher_element: bool) -> bool: self.hovered = True self.on_hovered() - hovered_higher_element = True self.while_hovering(time_delta, mouse_pos) else: diff --git a/pygame_gui/core/ui_window_stack.py b/pygame_gui/core/ui_window_stack.py index a3f0410b..8b3d5364 100644 --- a/pygame_gui/core/ui_window_stack.py +++ b/pygame_gui/core/ui_window_stack.py @@ -33,9 +33,10 @@ def add_new_window(self, window: IWindowInterface): :param window: The window to add. """ - new_layer = (self.stack[-1].get_top_layer() + new_layer = (self.stack[-1].get_top_layer() + 1 if len(self.stack) > 0 - else self.root_container.get_top_layer()) + else self.root_container.get_top_layer() + 1) + window.change_layer(new_layer) self.stack.append(window) window.on_moved_to_front() diff --git a/pygame_gui/elements/ui_drop_down_menu.py b/pygame_gui/elements/ui_drop_down_menu.py index 12ce795d..889e8469 100644 --- a/pygame_gui/elements/ui_drop_down_menu.py +++ b/pygame_gui/elements/ui_drop_down_menu.py @@ -673,6 +673,7 @@ def __init__(self, object_id=object_id, element_id='drop_down_menu') + self.__layer_thickness_including_expansion = 4 self.options_list = options_list self.selected_option = starting_option self.open_button_width = 20 @@ -723,6 +724,15 @@ def __init__(self, self.current_state = self.menu_states['closed'] self.current_state.start(should_rebuild=True) + @property + def layer_thickness(self): + return self.__layer_thickness_including_expansion + + @layer_thickness.setter + def layer_thickness(self, value): + # ignore passed in value and hardcode to 4 + self.__layer_thickness_including_expansion = 4 + def add_options(self, new_options: List[str]) -> None: """ Add new options to the drop down. Will close the drop down if it is currently open. From 76b4c1921bd330b71654bb928d96cbf66869ced9 Mon Sep 17 00:00:00 2001 From: Dan Lawrence Date: Sat, 29 Oct 2022 10:48:42 +0100 Subject: [PATCH 08/14] Correcting text hover pointer behaviour for entry lines and boxes. Was previously using fresh hover point calculation rather than hierarchical one already made every loop. --- pygame_gui/elements/ui_text_entry_box.py | 2 +- pygame_gui/elements/ui_text_entry_line.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pygame_gui/elements/ui_text_entry_box.py b/pygame_gui/elements/ui_text_entry_box.py index 55e9a649..c957d492 100644 --- a/pygame_gui/elements/ui_text_entry_box.py +++ b/pygame_gui/elements/ui_text_entry_box.py @@ -104,7 +104,7 @@ def update(self, time_delta: float): if not self.alive(): return scaled_mouse_pos = self.ui_manager.get_mouse_position() - if self.hover_point(scaled_mouse_pos[0], scaled_mouse_pos[1]): + if self.hovered: if (self.scroll_bar is None or (self.scroll_bar is not None and not self.scroll_bar.hover_point(scaled_mouse_pos[0], scaled_mouse_pos[1]))): diff --git a/pygame_gui/elements/ui_text_entry_line.py b/pygame_gui/elements/ui_text_entry_line.py index 56df533f..e507dd93 100644 --- a/pygame_gui/elements/ui_text_entry_line.py +++ b/pygame_gui/elements/ui_text_entry_line.py @@ -307,7 +307,7 @@ def update(self, time_delta: float): if not self.alive(): return scaled_mouse_pos = self.ui_manager.get_mouse_position() - if self.hover_point(scaled_mouse_pos[0], scaled_mouse_pos[1]): + if self.hovered: self.ui_manager.set_text_input_hovered(True) if self.double_click_timer < self.ui_manager.get_double_click_time(): From 1401a1c91e3dd309ee9aefb0575976ad127f05ae Mon Sep 17 00:00:00 2001 From: Dan Lawrence Date: Sat, 29 Oct 2022 16:43:03 +0100 Subject: [PATCH 09/14] Increase robustness of html parser Support empty text entry box. --- .../core/drawable_shapes/drawable_shape.py | 7 ++- pygame_gui/core/text/html_parser.py | 55 +++++++++++++------ pygame_gui/core/text/text_box_layout.py | 22 ++++++-- pygame_gui/elements/ui_text_box.py | 33 +++++++++-- pygame_gui/elements/ui_text_entry_box.py | 3 +- 5 files changed, 90 insertions(+), 30 deletions(-) diff --git a/pygame_gui/core/drawable_shapes/drawable_shape.py b/pygame_gui/core/drawable_shapes/drawable_shape.py index a8a1c13b..2a9b1472 100644 --- a/pygame_gui/core/drawable_shapes/drawable_shape.py +++ b/pygame_gui/core/drawable_shapes/drawable_shape.py @@ -516,9 +516,14 @@ def build_text_layout(self): max_dimensions=(text_actual_area_rect.width, text_actual_area_rect.height)) text_chunk.should_centre_from_baseline = True + default_font_data = {"font": self.theming['font'], + "font_colour": (self.theming['normal_text'] + if 'normal_text' in self.theming else + self.ui_manager.get_theme().get_colour('normal_text', None)), + "bg_colour": pygame.Color('#00000000')} self.text_box_layout = TextBoxLayout(deque([text_chunk]), text_actual_area_rect, self.text_view_rect, line_spacing=1.25, - default_font=self.theming['font']) + default_font_data=default_font_data) if 'selected_bg' in self.theming: self.text_box_layout.selection_colour = self.theming['selected_bg'] if 'text_cursor_colour' in self.theming: diff --git a/pygame_gui/core/text/html_parser.py b/pygame_gui/core/text/html_parser.py index 1a5b5c25..3c6e249c 100644 --- a/pygame_gui/core/text/html_parser.py +++ b/pygame_gui/core/text/html_parser.py @@ -166,7 +166,7 @@ def _handle_shadow_tag(self, attributes, style): shadow_offset[0] = int(offset_str[0]) shadow_offset[1] = int(offset_str[1]) if 'color' in attributes: - if attributes['color'][0] == '#': + if self._is_legal_hex_colour(attributes['color']): shadow_colour = pygame.color.Color(attributes['color']) else: shadow_colour = self.ui_theme.get_colour_or_gradient(attributes['color'], @@ -179,35 +179,42 @@ def _handle_font_tag(self, attributes, style): font_name = attributes['face'] if len(attributes['face']) > 0 else None style["font_name"] = font_name if 'pixel_size' in attributes: - if len(attributes['pixel_size']) > 0: + if attributes['pixel_size'] is not None and len(attributes['pixel_size']) > 0: font_size = int(attributes['pixel_size']) else: - font_size = None + font_size = self.default_style['font_size'] style["font_size"] = font_size elif 'size' in attributes: - if len(attributes['size']) > 0: - if float(attributes['size']) in self.font_sizes: - font_size = self.font_sizes[float(attributes['size'])] - else: - warnings.warn('Size of: ' + str(float(attributes['size'])) + - " - is not a supported html style size." - " Try .5 increments between 1 & 7 or use 'pixel_size' instead to " - "set the font size directly", category=UserWarning) + if attributes['size'] is not None and len(attributes['size']) > 0: + try: + if float(attributes['size']) in self.font_sizes: + font_size = self.font_sizes[float(attributes['size'])] + else: + warnings.warn('Size of: ' + str(float(attributes['size'])) + + " - is not a supported html style size." + " Try .5 increments between 1 & 7 or use 'pixel_size' instead to " + "set the font size directly", category=UserWarning) + font_size = self.default_style['font_size'] + except ValueError or AttributeError: font_size = self.default_style['font_size'] else: - font_size = None + font_size = self.default_style['font_size'] style["font_size"] = font_size if 'color' in attributes: - if attributes['color'][0] == '#': - style["font_colour"] = pygame.color.Color(attributes['color']) - else: - style["font_colour"] = self.ui_theme.get_colour_or_gradient(attributes['color'], - self.combined_ids) + style["font_colour"] = self.default_style["font_colour"] + try: + if self._is_legal_hex_colour(attributes['color']): + style["font_colour"] = pygame.color.Color(attributes['color']) + elif attributes['color'] is not None and len(attributes['color']) > 0: + style["font_colour"] = self.ui_theme.get_colour_or_gradient(attributes['color'], + self.combined_ids) + except ValueError or AttributeError: + style["font_colour"] = self.default_style["font_colour"] def _handle_body_tag(self, attributes, style): if 'bgcolor' in attributes: if len(attributes['bgcolor']) > 0: - if attributes['bgcolor'][0] == '#': + if self._is_legal_hex_colour(attributes['bgcolor']): style["bg_colour"] = pygame.color.Color(attributes['bgcolor']) else: style["bg_colour"] = self.ui_theme.get_colour_or_gradient( @@ -405,3 +412,15 @@ def create_styled_text_chunk(self, text: str): text_shadow_data=self.current_style['shadow_data'], effect_id=self.current_style['effect_id']) return chunk + + @staticmethod + def _is_legal_hex_colour(col): + if col is not None and len(col) > 0 and col[0] == '#': + if len(col) == 7 or len(col) == 9: + for col_index in range(1, len(col)): + col_letter = col[col_index] + if col_letter not in ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'a', 'A', 'b', 'B', 'c', 'C', 'd', 'D', 'e', 'E', 'f', 'F']: + return False + return True + return False diff --git a/pygame_gui/core/text/text_box_layout.py b/pygame_gui/core/text/text_box_layout.py index 8efd077c..4ef539b8 100644 --- a/pygame_gui/core/text/text_box_layout.py +++ b/pygame_gui/core/text/text_box_layout.py @@ -1,4 +1,4 @@ -from typing import Deque, List, Optional +from typing import Deque, List, Optional, Dict, Any from collections import deque from bisect import bisect_left @@ -32,13 +32,17 @@ def __init__(self, layout_rect: pygame.Rect, view_rect: pygame.Rect, line_spacing: float, - default_font, + default_font_data: Dict[str, Any], allow_split_dashes: bool = True): # TODO: supply only a width and create final rect shape or just a final height? self.input_data_rect_queue = input_data_queue.copy() self.layout_rect = layout_rect.copy() self.line_spacing = line_spacing - self.default_font = default_font # this is the font used when we don't have anything else + + # this is the font used when we don't have anything else + self.default_font_data = default_font_data + self.default_font = default_font_data["font"] + self.allow_split_dashes = allow_split_dashes self.last_row_height = int(14 * self.line_spacing) @@ -68,7 +72,17 @@ def __init__(self, current_row = TextBoxLayoutRow(row_start_x=self.layout_rect.x, row_start_y=self.layout_rect.y, row_index=0, layout=self, line_spacing=self.line_spacing) - self._process_layout_queue(self.layout_rect_queue, current_row) + if len(self.layout_rect_queue) > 0: + self._process_layout_queue(self.layout_rect_queue, current_row) + else: + empty_text_chunk = TextLineChunkFTFont("", + self.default_font, + False, + self.default_font_data['font_colour'], + True, + self.default_font_data['bg_colour']) + current_row.add_item(empty_text_chunk) + self._add_row_to_layout(current_row, True) self.edit_buffer = 2 self.cursor_text_row = None diff --git a/pygame_gui/elements/ui_text_box.py b/pygame_gui/elements/ui_text_box.py index c761a9f1..c17c6e98 100644 --- a/pygame_gui/elements/ui_text_box.py +++ b/pygame_gui/elements/ui_text_box.py @@ -87,7 +87,8 @@ def __init__(self, *, pre_parsing_enabled: bool = True, text_kwargs: Optional[Dict[str, str]] = None, - allow_split_dashes: bool = True): + allow_split_dashes: bool = True, + plain_text_display_only: bool = False): super().__init__(relative_rect, manager, container, starting_height=layer_starting_height, @@ -108,6 +109,7 @@ def __init__(self, self.text_kwargs = text_kwargs self.font_dict = self.ui_theme.get_font_dictionary() self.allow_split_dashes = allow_split_dashes + self.plain_text_display_only = plain_text_display_only self._pre_parsing_enabled = pre_parsing_enabled @@ -535,16 +537,28 @@ def parse_html_into_style_data(self): Parses HTML styled string text into a format more useful for styling pygame.freetype rendered text. """ - - self.parser.feed(self._pre_parse_text(translate(self.html_text, **self.text_kwargs) + self.appended_text)) - + feed_input = self.html_text + if self.plain_text_display_only: + feed_input = html.escape(feed_input) # might have to add true to second param here for quotes + feed_input = self._pre_parse_text(translate(feed_input, **self.text_kwargs) + self.appended_text) + self.parser.feed(feed_input) + + default_font = self.ui_theme.get_font_dictionary().find_font( + font_name=self.parser.default_style['font_name'], + font_size=self.parser.default_style['font_size'], + bold=self.parser.default_style['bold'], + italic=self.parser.default_style['italic']) + default_font_data = {"font": default_font, + "font_colour": self.parser.default_style['font_colour'], + "bg_colour": self.parser.default_style['bg_colour'] + } self.text_box_layout = TextBoxLayout(self.parser.layout_rect_queue, pygame.Rect((0, 0), (self.text_wrap_rect[2], self.text_wrap_rect[3])), pygame.Rect((0, 0), (self.text_wrap_rect[2], self.text_wrap_rect[3])), line_spacing=1.25, - default_font=self.ui_theme.get_font_dictionary().get_default_font(), + default_font_data=default_font_data, allow_split_dashes=self.allow_split_dashes) self.parser.empty_layout_queue() if self.text_wrap_rect[3] == -1: @@ -886,7 +900,14 @@ def append_html_text(self, new_html_str: str): :param new_html_str: The, potentially HTML tag, containing string of text to append. """ self.appended_text += html.unescape(new_html_str) - self.parser.feed(self._pre_parse_text(new_html_str)) + feed_input = self.appended_text + if self.plain_text_display_only: + # if we are supporting only plain text rendering then we turn html input into text at this point + feed_input = html.escape(feed_input) # might have to add true to second param here for quotes + # this converts plain text special characters to html just for the parser + feed_input = self._pre_parse_text(feed_input) + + self.parser.feed(feed_input) self.text_box_layout.append_layout_rects(self.parser.layout_rect_queue) self.parser.empty_layout_queue() diff --git a/pygame_gui/elements/ui_text_entry_box.py b/pygame_gui/elements/ui_text_entry_box.py index c957d492..e2c4fa15 100644 --- a/pygame_gui/elements/ui_text_entry_box.py +++ b/pygame_gui/elements/ui_text_entry_box.py @@ -30,7 +30,8 @@ def __init__(self, object_id=object_id, anchors=anchors, visible=visible, - allow_split_dashes=False) + allow_split_dashes=False, + plain_text_display_only=True) # input timings - I expect nobody really wants to mess with these that much # ideally we could populate from the os settings but that sounds like a headache From 603af709f8a5c8088d3dc1cf1a62c72f5afc44e7 Mon Sep 17 00:00:00 2001 From: Dan Lawrence Date: Sat, 29 Oct 2022 17:07:01 +0100 Subject: [PATCH 10/14] Further increase robustness of html parser --- pygame_gui/core/text/html_parser.py | 63 ++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 19 deletions(-) diff --git a/pygame_gui/core/text/html_parser.py b/pygame_gui/core/text/html_parser.py index 3c6e249c..798260b1 100644 --- a/pygame_gui/core/text/html_parser.py +++ b/pygame_gui/core/text/html_parser.py @@ -160,27 +160,44 @@ def _handle_shadow_tag(self, attributes, style): shadow_offset = [0, 0] shadow_colour = [50, 50, 50] if 'size' in attributes: - shadow_size = int(attributes['size']) + if attributes['size'] is not None and len(attributes['size']) > 0: + try: + shadow_size = int(attributes['size']) + except ValueError or AttributeError: + shadow_size = 0 + if 'offset' in attributes: - offset_str = attributes['offset'].split(',') - shadow_offset[0] = int(offset_str[0]) - shadow_offset[1] = int(offset_str[1]) + if attributes['offset'] is not None and len(attributes['offset']) > 0: + try: + offset_str = attributes['offset'].split(',') + if len(offset_str) == 2: + shadow_offset[0] = int(offset_str[0]) + shadow_offset[1] = int(offset_str[1]) + except ValueError or AttributeError: + shadow_offset = [0, 0] if 'color' in attributes: - if self._is_legal_hex_colour(attributes['color']): - shadow_colour = pygame.color.Color(attributes['color']) - else: - shadow_colour = self.ui_theme.get_colour_or_gradient(attributes['color'], - self.combined_ids) + try: + if self._is_legal_hex_colour(attributes['color']): + shadow_colour = pygame.color.Color(attributes['color']) + elif attributes['color'] is not None and len(attributes['color']) > 0: + shadow_colour = self.ui_theme.get_colour_or_gradient(attributes['color'], + self.combined_ids) + except ValueError or AttributeError: + shadow_colour = [50, 50, 50] + style['shadow_data'] = (shadow_size, shadow_offset[0], shadow_offset[1], shadow_colour, False) def _handle_font_tag(self, attributes, style): - if 'face' in attributes: + if 'face' in attributes and attributes['face'] is not None: font_name = attributes['face'] if len(attributes['face']) > 0 else None style["font_name"] = font_name if 'pixel_size' in attributes: if attributes['pixel_size'] is not None and len(attributes['pixel_size']) > 0: - font_size = int(attributes['pixel_size']) + try: + font_size = int(attributes['pixel_size']) + except ValueError or AttributeError: + font_size = self.default_style['font_size'] else: font_size = self.default_style['font_size'] style["font_size"] = font_size @@ -213,13 +230,16 @@ def _handle_font_tag(self, attributes, style): def _handle_body_tag(self, attributes, style): if 'bgcolor' in attributes: - if len(attributes['bgcolor']) > 0: - if self._is_legal_hex_colour(attributes['bgcolor']): - style["bg_colour"] = pygame.color.Color(attributes['bgcolor']) - else: - style["bg_colour"] = self.ui_theme.get_colour_or_gradient( - attributes['bgcolor'], - self.combined_ids) + if attributes['bg_colour'] is not None and len(attributes['bgcolor']) > 0: + try: + if self._is_legal_hex_colour(attributes['bgcolor']): + style["bg_colour"] = pygame.color.Color(attributes['bgcolor']) + else: + style["bg_colour"] = self.ui_theme.get_colour_or_gradient( + attributes['bgcolor'], + self.combined_ids) + except ValueError or AttributeError: + style["bg_colour"] = pygame.Color('#00000000') else: style["bg_colour"] = pygame.Color('#00000000') @@ -257,7 +277,7 @@ def _handle_img_tag(self, attributes): image_float = TextFloatPosition.RIGHT else: image_float = TextFloatPosition.NONE - if 'padding' in attributes: + if 'padding' in attributes and isinstance(attributes['padding'], str): paddings = attributes['padding'].split(' ') for index, padding in enumerate(paddings): paddings[index] = int(padding.strip('px')) @@ -281,6 +301,11 @@ def _handle_img_tag(self, attributes): padding_right = paddings[0] padding_left = paddings[0] padding_bottom = paddings[0] + else: + padding_top = 0 + padding_right = 0 + padding_left = 0 + padding_bottom = 0 all_paddings = Padding(padding_top, padding_right, padding_bottom, padding_left) self.layout_rect_queue.append(ImageLayoutRect(image_path, image_float, From 5a0d2d07dd4223b10d2463bd5bd11924284c66fc Mon Sep 17 00:00:00 2001 From: Dan Lawrence Date: Sat, 29 Oct 2022 17:36:20 +0100 Subject: [PATCH 11/14] add copy and paste shortcuts to text entry box --- pygame_gui/elements/ui_text_entry_box.py | 88 +++++++++++++++++++++++- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/pygame_gui/elements/ui_text_entry_box.py b/pygame_gui/elements/ui_text_entry_box.py index e2c4fa15..a85e05e3 100644 --- a/pygame_gui/elements/ui_text_entry_box.py +++ b/pygame_gui/elements/ui_text_entry_box.py @@ -1,7 +1,10 @@ import re from typing import Union, Tuple, Dict, Optional +from pygame_gui.core.utility import clipboard_paste, clipboard_copy + from pygame import Rect, MOUSEBUTTONDOWN, MOUSEBUTTONUP, BUTTON_LEFT, KEYDOWN +from pygame import KMOD_META, KMOD_CTRL, K_a, K_x, K_c, K_v from pygame import K_LEFT, K_RIGHT, K_UP, K_DOWN, K_HOME, K_END, K_BACKSPACE, K_DELETE, K_RETURN from pygame import key from pygame.event import Event, post @@ -195,8 +198,8 @@ def process_event(self, event: Event) -> bool: if self._process_mouse_button_event(event): consumed_event = True if self.is_enabled and self.is_focused and event.type == KEYDOWN: - # if self._process_keyboard_shortcut_event(event): - # consumed_event = True + if self._process_keyboard_shortcut_event(event): + consumed_event = True if self._process_action_key_event(event): consumed_event = True elif self._process_text_entry_key(event): @@ -429,6 +432,82 @@ def _process_text_entry_key(self, event: Event) -> bool: consumed_event = True return consumed_event + def _process_keyboard_shortcut_event(self, event: Event) -> bool: + """ + Check if event is one of the CTRL key keyboard shortcuts. + + :param event: event to process. + + :return: True if event consumed. + + """ + consumed_event = False + if (event.key == K_a and + (event.mod & KMOD_CTRL or event.mod & KMOD_META)): + self.select_range = [0, len(self.html_text)] + self.edit_position = len(self.html_text) + self.cursor_has_moved_recently = True + consumed_event = True + elif (event.key == K_x and + (event.mod & KMOD_CTRL or event.mod & KMOD_META)): + if abs(self.select_range[0] - self.select_range[1]) > 0: + low_end = min(self.select_range[0], self.select_range[1]) + high_end = max(self.select_range[0], self.select_range[1]) + clipboard_copy(self.html_text[low_end:high_end]) + self.text_box_layout.delete_selected_text() + self.edit_position = low_end + self.html_text = self.html_text[:low_end] + self.html_text[high_end:] + self.text_box_layout.set_cursor_position(self.edit_position) + self.select_range = [0, 0] + self.redraw_from_text_block() + self.cursor_has_moved_recently = True + consumed_event = True + elif (event.key == K_c and + (event.mod & KMOD_CTRL or event.mod & KMOD_META)): + if abs(self.select_range[0] - self.select_range[1]) > 0: + low_end = min(self.select_range[0], self.select_range[1]) + high_end = max(self.select_range[0], self.select_range[1]) + clipboard_copy(self.html_text[low_end:high_end]) + consumed_event = True + elif self._process_paste_event(event): + consumed_event = True + return consumed_event + + def _process_paste_event(self, event: Event) -> bool: + """ + Process a paste shortcut event. (CTRL+ V) + + :param event: The event to process. + + :return: True if the event is consumed. + + """ + consumed_event = False + if event.key == K_v and (event.mod & KMOD_CTRL or event.mod & KMOD_META): + new_text = self.convert_all_line_endings_to_unix(clipboard_paste()) + + if abs(self.select_range[0] - self.select_range[1]) > 0: + low_end = min(self.select_range[0], self.select_range[1]) + high_end = max(self.select_range[0], self.select_range[1]) + self.html_text = self.html_text[:low_end] + new_text + self.html_text[high_end:] + self.set_text(self.html_text) + self.edit_position = low_end + len(new_text) + self.text_box_layout.set_cursor_position(self.edit_position) + self.redraw_from_text_block() + self.select_range = [0, 0] + self.cursor_has_moved_recently = True + elif len(new_text) > 0: + self.html_text = (self.html_text[:self.edit_position] + + new_text + + self.html_text[self.edit_position:]) + self.set_text(self.html_text) + self.edit_position += len(new_text) + self.text_box_layout.set_cursor_position(self.edit_position) + self.redraw_from_text_block() + self.cursor_has_moved_recently = True + consumed_event = True + return consumed_event + def _calculate_double_click_word_selection(self): """ If we double clicked on a word in the text, select that word. @@ -474,3 +553,8 @@ def _calculate_double_click_word_selection(self): def redraw_from_text_block(self): self.text_box_layout.fit_layout_rect_height_to_rows() super().redraw_from_text_block() + + @staticmethod + def convert_all_line_endings_to_unix(input_str: str): + return input_str.replace('\r\n', '\n').replace('\r', '\n') + From f177c61ae9ad9ec34426a0b8d24681c3cb510504 Mon Sep 17 00:00:00 2001 From: Dan Lawrence Date: Sun, 30 Oct 2022 11:40:05 +0000 Subject: [PATCH 12/14] fix failing tests --- pygame_gui/core/text/html_parser.py | 2 +- .../core/text/simple_test_layout_rect.py | 2 +- pygame_gui/core/text/text_box_layout.py | 2 +- pygame_gui/core/text/text_layout_rect.py | 2 +- pygame_gui/core/text/text_line_chunk.py | 2 +- pygame_gui/elements/ui_text_box.py | 20 +- pygame_gui/elements/ui_text_entry_box.py | 3 +- tests/test_core/test_text/test_html_parser.py | 6 +- .../test_text/test_line_break_layout_rect.py | 6 +- .../test_text/test_text_box_layout.py | 248 ++++++++++++++---- .../test_text/test_text_box_layout_row.py | 132 ++++++++-- .../test_elements/test_ui_text_entry_line.py | 17 +- tests/test_elements/test_ui_window.py | 6 +- tests/test_ui_manager.py | 8 +- 14 files changed, 362 insertions(+), 94 deletions(-) diff --git a/pygame_gui/core/text/html_parser.py b/pygame_gui/core/text/html_parser.py index 798260b1..d6b15b7d 100644 --- a/pygame_gui/core/text/html_parser.py +++ b/pygame_gui/core/text/html_parser.py @@ -230,7 +230,7 @@ def _handle_font_tag(self, attributes, style): def _handle_body_tag(self, attributes, style): if 'bgcolor' in attributes: - if attributes['bg_colour'] is not None and len(attributes['bgcolor']) > 0: + if attributes['bgcolor'] is not None and len(attributes['bgcolor']) > 0: try: if self._is_legal_hex_colour(attributes['bgcolor']): style["bg_colour"] = pygame.color.Color(attributes['bgcolor']) diff --git a/pygame_gui/core/text/simple_test_layout_rect.py b/pygame_gui/core/text/simple_test_layout_rect.py index 13c3f0ca..c552882c 100644 --- a/pygame_gui/core/text/simple_test_layout_rect.py +++ b/pygame_gui/core/text/simple_test_layout_rect.py @@ -56,7 +56,7 @@ def finalise(self, surface.fill(self.colour) target_surface.blit(surface, self, area=target_area) - def split(self, requested_x: int, line_width: int, row_start_x: int, allow_split_dashes: bool): + def split(self, requested_x: int, line_width: int, row_start_x: int, allow_split_dashes: bool = True): if line_width < self.smallest_split_size: raise ValueError('Line width is too narrow') diff --git a/pygame_gui/core/text/text_box_layout.py b/pygame_gui/core/text/text_box_layout.py index 4ef539b8..01958afd 100644 --- a/pygame_gui/core/text/text_box_layout.py +++ b/pygame_gui/core/text/text_box_layout.py @@ -250,7 +250,7 @@ def _handle_span_rect(self, current_row, test_layout_rect): row_start_y=current_row.bottom, row_index=len(self.layout_rows), layout=self, line_spacing=self.line_spacing) - current_row = prev_row_font + current_row.fall_back_font = prev_row_font # Make the rect span the current row's full width & add it to the row test_layout_rect.width = self.layout_rect.width # TODO: floating rects? diff --git a/pygame_gui/core/text/text_layout_rect.py b/pygame_gui/core/text/text_layout_rect.py index dd1c0b89..1b5e161e 100644 --- a/pygame_gui/core/text/text_layout_rect.py +++ b/pygame_gui/core/text/text_layout_rect.py @@ -107,7 +107,7 @@ def split(self, requested_x: int, line_width: int, row_start_x: int, - allow_split_dashes: bool) -> Union['TextLayoutRect', None]: # noqa + allow_split_dashes: bool = True) -> Union['TextLayoutRect', None]: # noqa """ Try to perform a split operation on this rectangle. Often rectangles will be split at the nearest point that is still less than the request (i.e. to the left of the request in diff --git a/pygame_gui/core/text/text_line_chunk.py b/pygame_gui/core/text/text_line_chunk.py index 29a9b887..69b6cdf2 100644 --- a/pygame_gui/core/text/text_line_chunk.py +++ b/pygame_gui/core/text/text_line_chunk.py @@ -377,7 +377,7 @@ def split(self, requested_x: int, line_width: int, row_start_x: int, - allow_split_dashes: bool) -> Union['TextLayoutRect', None]: + allow_split_dashes: bool = True) -> Union['TextLayoutRect', None]: """ Try to perform a split operation on this chunk at the requested pixel position. diff --git a/pygame_gui/elements/ui_text_box.py b/pygame_gui/elements/ui_text_box.py index c17c6e98..471f6550 100644 --- a/pygame_gui/elements/ui_text_box.py +++ b/pygame_gui/elements/ui_text_box.py @@ -88,7 +88,8 @@ def __init__(self, pre_parsing_enabled: bool = True, text_kwargs: Optional[Dict[str, str]] = None, allow_split_dashes: bool = True, - plain_text_display_only: bool = False): + plain_text_display_only: bool = False, + should_html_unescape_input_text: bool = False): super().__init__(relative_rect, manager, container, starting_height=layer_starting_height, @@ -101,8 +102,11 @@ def __init__(self, parent_element=parent_element, object_id=object_id, element_id='text_box') - - self.html_text = html.unescape(html_text) + self.should_html_unescape_input_text = should_html_unescape_input_text + if self.should_html_unescape_input_text: + self.html_text = html.unescape(html_text) + else: + self.html_text = html_text self.appended_text = "" self.text_kwargs = {} if text_kwargs is not None: @@ -899,7 +903,10 @@ def append_html_text(self, new_html_str: str): :param new_html_str: The, potentially HTML tag, containing string of text to append. """ - self.appended_text += html.unescape(new_html_str) + if self.should_html_unescape_input_text: + self.appended_text += html.unescape(new_html_str) + else: + self.appended_text += new_html_str feed_input = self.appended_text if self.plain_text_display_only: # if we are supporting only plain text rendering then we turn html input into text at this point @@ -1107,7 +1114,10 @@ def get_object_id(self) -> str: return self.most_specific_combined_id def set_text(self, html_text: str, *, text_kwargs: Optional[Dict[str, str]] = None): - self.html_text = html.unescape(html_text) + if self.should_html_unescape_input_text: + self.html_text = html.unescape(html_text) + else: + self.html_text = html_text if text_kwargs is not None: self.text_kwargs = text_kwargs else: diff --git a/pygame_gui/elements/ui_text_entry_box.py b/pygame_gui/elements/ui_text_entry_box.py index a85e05e3..25f93306 100644 --- a/pygame_gui/elements/ui_text_entry_box.py +++ b/pygame_gui/elements/ui_text_entry_box.py @@ -34,7 +34,8 @@ def __init__(self, anchors=anchors, visible=visible, allow_split_dashes=False, - plain_text_display_only=True) + plain_text_display_only=True, + should_html_unescape_input_text=True) # input timings - I expect nobody really wants to mess with these that much # ideally we could populate from the os settings but that sounds like a headache diff --git a/tests/test_core/test_text/test_html_parser.py b/tests/test_core/test_text/test_html_parser.py index 7b5802e0..954ac9ad 100644 --- a/tests/test_core/test_text/test_html_parser.py +++ b/tests/test_core/test_text/test_html_parser.py @@ -71,11 +71,11 @@ def test_handle_start_tag(self, _init_pygame, default_ui_manager: UIManager): parser.handle_starttag(tag='font', attrs=[('pixel_size', "")]) - assert parser.current_style['font_size'] is None + assert parser.current_style['font_size'] is 14 parser.handle_starttag(tag='font', attrs=[('size', "")]) - assert parser.current_style['font_size'] is None + assert parser.current_style['font_size'] is 14 with pytest.warns(UserWarning, match="not a supported html style size"): parser.handle_starttag(tag='font', attrs=[('size', "8")]) @@ -92,7 +92,7 @@ def test_handle_start_tag(self, _init_pygame, default_ui_manager: UIManager): parser.handle_starttag(tag='p', attrs=[]) parser.handle_starttag(tag='p', attrs=[]) - assert isinstance(parser.layout_rect_queue[-1], LineBreakLayoutRect) + assert isinstance(parser.layout_rect_queue[-2], LineBreakLayoutRect) parser.handle_starttag(tag='img', attrs=[('src', 'tests/data/images/splat.png'), ('float', 'none'), diff --git a/tests/test_core/test_text/test_line_break_layout_rect.py b/tests/test_core/test_text/test_line_break_layout_rect.py index 8d3d15a0..3e7f383f 100644 --- a/tests/test_core/test_text/test_line_break_layout_rect.py +++ b/tests/test_core/test_text/test_line_break_layout_rect.py @@ -8,13 +8,15 @@ class TestLineBreakLayoutRect: def test_creation(self, _init_pygame, default_ui_manager: UIManager): - line_break_rect = LineBreakLayoutRect(dimensions=(200, 30)) + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + line_break_rect = LineBreakLayoutRect(dimensions=(200, 30), font=default_font) assert line_break_rect.width == 200 assert line_break_rect.height == 30 def test_finalise(self, _init_pygame, default_ui_manager: UIManager): - line_break_rect = LineBreakLayoutRect(dimensions=(200, 30)) + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + line_break_rect = LineBreakLayoutRect(dimensions=(200, 30), font=default_font) rendered_chunk_surf = pygame.Surface((200, 30)) rendered_chunk_surf.fill((0, 0, 0)) diff --git a/tests/test_core/test_text/test_text_box_layout.py b/tests/test_core/test_text/test_text_box_layout.py index 536b883f..868b7ebc 100644 --- a/tests/test_core/test_text/test_text_box_layout.py +++ b/tests/test_core/test_text/test_text_box_layout.py @@ -14,10 +14,16 @@ class TestTextBoxLayout: def test_creation(self, _init_pygame, default_ui_manager: UIManager): input_data = deque([]) + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 200, 300), view_rect=pygame.Rect(0, 0, 200, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) def test_creation_with_data(self, _init_pygame, default_ui_manager: UIManager): input_data = deque([SimpleTestLayoutRect(dimensions=(50, 20)), @@ -61,11 +67,16 @@ def test_creation_with_data(self, _init_pygame, default_ui_manager: UIManager): SimpleTestLayoutRect(dimensions=(30, 20)), SimpleTestLayoutRect(dimensions=(30, 20)) ]) - + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 200, 110), view_rect=pygame.Rect(0, 0, 200, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) assert len(layout.layout_rows) > 0 @@ -79,7 +90,8 @@ def test_creation_with_data(self, _init_pygame, default_ui_manager: UIManager): layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 100, 110), view_rect=pygame.Rect(0, 0, 200, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) assert len(layout.layout_rows) > 0 @@ -93,7 +105,8 @@ def test_creation_with_data(self, _init_pygame, default_ui_manager: UIManager): layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 100, 110), view_rect=pygame.Rect(0, 0, 200, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) assert len(layout.layout_rows) > 0 @@ -116,7 +129,8 @@ def test_creation_with_data(self, _init_pygame, default_ui_manager: UIManager): layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 100, 90), view_rect=pygame.Rect(0, 0, 200, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) assert len(layout.layout_rows) > 0 @@ -132,10 +146,16 @@ def test_too_wide_image(self, _init_pygame, default_ui_manager: UIManager): padding=Padding(0, 0, 0, 0))]) with pytest.warns(UserWarning, match="too wide for text layout"): + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 200, 300), view_rect=pygame.Rect(0, 0, 200, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) def test_reprocess_layout_queue(self, _init_pygame, default_ui_manager: UIManager): input_data = deque([SimpleTestLayoutRect(dimensions=(50, 20)), @@ -146,11 +166,16 @@ def test_reprocess_layout_queue(self, _init_pygame, default_ui_manager: UIManage SimpleTestLayoutRect(dimensions=(30, 20)), SimpleTestLayoutRect(dimensions=(90, 20)), SimpleTestLayoutRect(dimensions=(175, 20))]) - + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 200, 300), view_rect=pygame.Rect(0, 0, 200, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) assert len(layout.layout_rows) == 4 layout.reprocess_layout_queue(pygame.Rect(0, 0, 100, 300)) @@ -166,10 +191,16 @@ def test_finalise_to_surf(self, _init_pygame, default_ui_manager: UIManager): SimpleTestLayoutRect(dimensions=(90, 20)), SimpleTestLayoutRect(dimensions=(175, 20))]) + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 200, 300), view_rect=pygame.Rect(0, 0, 200, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) layout_surface = pygame.Surface((200, 300), depth=32, flags=pygame.SRCALPHA) layout_surface.fill((0, 0, 0, 0)) @@ -187,10 +218,16 @@ def test_finalise_to_new(self, _init_pygame, default_ui_manager: UIManager): SimpleTestLayoutRect(dimensions=(90, 20)), SimpleTestLayoutRect(dimensions=(175, 20))]) + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 200, 300), view_rect=pygame.Rect(0, 0, 200, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) layout_surface = layout.finalise_to_new() @@ -204,10 +241,16 @@ def test_update_text_with_new_text_end_pos(self, _init_pygame, default_ui_manage using_default_text_colour=False, bg_colour=pygame.Color('#FF0000'))]) + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 200, 300), view_rect=pygame.Rect(0, 0, 200, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) layout_surface = layout.finalise_to_new() layout.update_text_with_new_text_end_pos(0) # this does nothing unless we pass in text @@ -226,10 +269,16 @@ def test_clear_final_surface(self, _init_pygame, default_ui_manager: UIManager) SimpleTestLayoutRect(dimensions=(90, 20)), SimpleTestLayoutRect(dimensions=(175, 20))]) + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 200, 300), view_rect=pygame.Rect(0, 0, 200, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) layout_surface = layout.finalise_to_new() @@ -247,10 +296,16 @@ def test_set_alpha(self, _init_pygame, default_ui_manager: UIManager): SimpleTestLayoutRect(dimensions=(90, 20)), SimpleTestLayoutRect(dimensions=(175, 20))]) + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 200, 300), view_rect=pygame.Rect(0, 0, 200, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) layout_surface = layout.finalise_to_new() @@ -281,10 +336,16 @@ def test_add_chunks_to_hover_group(self, _init_pygame, default_ui_manager: UIMan hover_underline=False) ]) + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 200, 300), view_rect=pygame.Rect(0, 0, 200, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) links_found = [] layout.add_chunks_to_hover_group(links_found) @@ -312,10 +373,16 @@ def test_insert_layout_rects(self, _init_pygame, default_ui_manager: UIManager): bg_colour=pygame.Color('#FF0000')), ]) + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 300, 150), view_rect=pygame.Rect(0, 0, 300, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) insert_data = deque([TextLineChunkFTFont(text='n insertion', font=the_font, @@ -330,9 +397,9 @@ def test_insert_layout_rects(self, _init_pygame, default_ui_manager: UIManager): chunk_index=9) row = layout.layout_rows[0] - chunk = row.items[1] + chunk = row.items[0] - assert chunk.text == 'this is an insertion' + assert chunk.text == 'hello this is an insertion test' layout_surface = layout.finalise_to_new() @@ -399,11 +466,16 @@ def test_horiz_centre_all_rows(self, _init_pygame, default_ui_manager: UIManager using_default_text_colour=False, bg_colour=pygame.Color('#FF0000')), ]) - + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 500, 300), view_rect=pygame.Rect(0, 0, 500, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) row = layout.layout_rows[0] assert row.x == 0 @@ -434,10 +506,16 @@ def test_align_left_all_rows(self, _init_pygame, default_ui_manager: UIManager): bg_colour=pygame.Color('#FF0000')), ]) + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 500, 300), view_rect=pygame.Rect(0, 0, 500, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) row = layout.layout_rows[0] assert row.x == 0 @@ -471,11 +549,16 @@ def test_align_right_all_rows(self, _init_pygame, default_ui_manager: UIManager) using_default_text_colour=False, bg_colour=pygame.Color('#FF0000')), ]) - + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 500, 300), view_rect=pygame.Rect(0, 0, 500, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) row = layout.layout_rows[0] assert row.x == 0 @@ -504,11 +587,16 @@ def test_vert_center_all_rows(self, _init_pygame, default_ui_manager: UIManager) using_default_text_colour=False, bg_colour=pygame.Color('#FF0000')), ]) - + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 500, 300), view_rect=pygame.Rect(0, 0, 500, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) row = layout.layout_rows[0] assert row.y == 0 @@ -537,11 +625,16 @@ def test_vert_align_top_all_rows(self, _init_pygame, default_ui_manager: UIManag using_default_text_colour=False, bg_colour=pygame.Color('#FF0000')), ]) - + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 500, 300), view_rect=pygame.Rect(0, 0, 500, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) row = layout.layout_rows[0] assert row.y == 0 @@ -571,11 +664,16 @@ def test_vert_align_bottom_all_rows(self, _init_pygame, default_ui_manager: UIMa using_default_text_colour=False, bg_colour=pygame.Color('#FF0000')), ]) - + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 500, 300), view_rect=pygame.Rect(0, 0, 500, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) row = layout.layout_rows[0] assert row.y == 0 @@ -603,11 +701,16 @@ def test_set_cursor_position(self, _init_pygame, default_ui_manager: UIManager): using_default_text_colour=False, bg_colour=pygame.Color('#FF0000')), ]) - + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 100, 300), view_rect=pygame.Rect(0, 0, 100, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) layout.set_cursor_position(13) @@ -639,11 +742,16 @@ def test_set_cursor_from_click_pos(self, _init_pygame, default_ui_manager: UIMan using_default_text_colour=False, bg_colour=pygame.Color('#FF0000')), ]) - + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 100, 300), view_rect=pygame.Rect(0, 0, 100, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) layout.set_cursor_from_click_pos((17, 24)) assert layout.cursor_text_row is not None @@ -674,11 +782,16 @@ def test_toggle_cursor(self, _init_pygame, default_ui_manager: UIManager): using_default_text_colour=False, bg_colour=pygame.Color('#FF0000')), ]) - + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 100, 300), view_rect=pygame.Rect(0, 0, 100, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) layout.set_cursor_from_click_pos((17, 24)) @@ -716,11 +829,16 @@ def test_set_text_selection(self, _init_pygame, default_ui_manager: UIManager): using_default_text_colour=False, bg_colour=pygame.Color('#FF0000')) ]) - + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 100, 300), view_rect=pygame.Rect(0, 0, 100, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) layout.set_text_selection(5, 17) @@ -756,11 +874,16 @@ def test_set_default_text_colour(self, _init_pygame, default_ui_manager: UIManag using_default_text_colour=False, bg_colour=pygame.Color('#FF0000')), ]) - + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 500, 300), view_rect=pygame.Rect(0, 0, 500, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) layout.set_default_text_colour(pygame.Color('#00FF00')) default_chunk_colour = layout.layout_rows[0].items[1].colour @@ -787,25 +910,31 @@ def test_insert_text(self, _init_pygame, default_ui_manager: UIManager): using_default_text_colour=False, bg_colour=pygame.Color('#FF0000')), ]) - + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 500, 300), view_rect=pygame.Rect(0, 0, 500, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) layout.insert_text('nother insertion', 15) row = layout.layout_rows[0] - chunk = row.items[1] + chunk = row.items[0] - assert chunk.text == 'this is another insertion' + assert chunk.text == 'hello this is another insertion test' layout.insert_text('text on the end', 51) # deliberately high number that should get clamped layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 500, 300), view_rect=pygame.Rect(0, 0, 500, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) layout.layout_rows = [] @@ -833,11 +962,16 @@ def test_delete_selected_text(self, _init_pygame, default_ui_manager: UIManager) using_default_text_colour=False, bg_colour=pygame.Color('#FF0000')), ]) - + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 100, 300), view_rect=pygame.Rect(0, 0, 100, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) layout.set_text_selection(5, 17) @@ -923,14 +1057,21 @@ def test_delete_at_cursor(self, _init_pygame, default_ui_manager: UIManager): using_default_text_colour=False, bg_colour=pygame.Color('#FF0000')), ]) - + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 100, 300), view_rect=pygame.Rect(0, 0, 100, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) layout.set_cursor_position(14) layout.delete_at_cursor() + # need to reset the cursor position before deleting again as chunks will have merged and been re-processed + layout.set_cursor_position(14) layout.delete_at_cursor() remaining_text = '' @@ -961,14 +1102,21 @@ def test_backspace_at_cursor(self, _init_pygame, default_ui_manager: UIManager): using_default_text_colour=False, bg_colour=pygame.Color('#FF0000')), ]) - + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 100, 300), view_rect=pygame.Rect(0, 0, 100, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) layout.set_cursor_position(16) layout.backspace_at_cursor() + # need to reset the cursor position before backspacing again as chunks will have merged and been re-processed + layout.set_cursor_position(15) layout.backspace_at_cursor() remaining_text = '' diff --git a/tests/test_core/test_text/test_text_box_layout_row.py b/tests/test_core/test_text/test_text_box_layout_row.py index 9b15b2e9..012bf009 100644 --- a/tests/test_core/test_text/test_text_box_layout_row.py +++ b/tests/test_core/test_text/test_text_box_layout_row.py @@ -12,10 +12,16 @@ class TestTextBoxLayoutRow: def test_creation(self, _init_pygame, default_ui_manager: UIManager): input_data = deque([]) + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } text_box_layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 200, 300), view_rect=pygame.Rect(0, 0, 200, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) layout_row = TextBoxLayoutRow(row_start_x=0, row_start_y=0, row_index=0, @@ -27,10 +33,16 @@ def test_creation(self, _init_pygame, default_ui_manager: UIManager): def test_at_start(self, _init_pygame, default_ui_manager: UIManager): input_data = deque([]) + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } text_box_layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 200, 300), view_rect=pygame.Rect(0, 0, 200, 150), - line_spacing=1.0) + line_spacing=1.0, + default_font_data=default_font_data) layout_row = TextBoxLayoutRow(row_start_x=0, row_start_y=0, row_index=0, @@ -45,10 +57,16 @@ def test_at_start(self, _init_pygame, default_ui_manager: UIManager): def test_add_item(self, _init_pygame, default_ui_manager: UIManager): input_data = deque([]) line_spacing = 1.25 + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } text_box_layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 200, 300), view_rect=pygame.Rect(0, 0, 200, 150), - line_spacing=line_spacing) + line_spacing=1.0, + default_font_data=default_font_data) layout_row = TextBoxLayoutRow(row_start_x=0, row_start_y=0, row_index=0, @@ -66,10 +84,16 @@ def test_add_item(self, _init_pygame, default_ui_manager: UIManager): def test_rewind_row(self, _init_pygame, default_ui_manager: UIManager): input_data = deque([]) line_spacing = 1.25 + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } text_box_layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 200, 300), view_rect=pygame.Rect(0, 0, 200, 150), - line_spacing=line_spacing) + line_spacing=1.0, + default_font_data=default_font_data) layout_row = TextBoxLayoutRow(row_start_x=0, row_start_y=0, row_index=0, @@ -93,10 +117,16 @@ def test_rewind_row(self, _init_pygame, default_ui_manager: UIManager): def test_horiz_center_row(self, _init_pygame, default_ui_manager: UIManager): input_data = deque([]) line_spacing = 1.25 + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } text_box_layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 200, 300), view_rect=pygame.Rect(0, 0, 200, 150), - line_spacing=line_spacing) + line_spacing=line_spacing, + default_font_data=default_font_data) layout_row = TextBoxLayoutRow(row_start_x=0, row_start_y=0, row_index=0, @@ -129,10 +159,16 @@ def test_horiz_center_row(self, _init_pygame, default_ui_manager: UIManager): def test_align_left_row(self, _init_pygame, default_ui_manager: UIManager): input_data = deque([]) line_spacing = 1.25 + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } text_box_layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 200, 300), view_rect=pygame.Rect(0, 0, 200, 150), - line_spacing=line_spacing) + line_spacing=line_spacing, + default_font_data=default_font_data) layout_row = TextBoxLayoutRow(row_start_x=0, row_start_y=0, row_index=0, @@ -159,10 +195,16 @@ def test_align_left_row(self, _init_pygame, default_ui_manager: UIManager): def test_align_right_row(self, _init_pygame, default_ui_manager: UIManager): input_data = deque([]) line_spacing = 1.25 + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } text_box_layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 200, 300), view_rect=pygame.Rect(0, 0, 200, 150), - line_spacing=line_spacing) + line_spacing=line_spacing, + default_font_data=default_font_data) layout_row = TextBoxLayoutRow(row_start_x=0, row_start_y=0, row_index=0, @@ -198,10 +240,16 @@ def test_align_right_row(self, _init_pygame, default_ui_manager: UIManager): def test_vert_align_items_to_row(self, _init_pygame, default_ui_manager: UIManager): input_data = deque([]) line_spacing = 1.25 + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } text_box_layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 200, 300), view_rect=pygame.Rect(0, 0, 200, 150), - line_spacing=line_spacing) + line_spacing=line_spacing, + default_font_data=default_font_data) layout_row = TextBoxLayoutRow(row_start_x=0, row_start_y=0, row_index=0, @@ -248,10 +296,16 @@ def test_vert_align_items_to_row(self, _init_pygame, default_ui_manager: UIManag def test_merge_adjacent_compatible_chunks(self, _init_pygame, default_ui_manager: UIManager): input_data = deque([]) line_spacing = 1.25 + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } text_box_layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 200, 300), view_rect=pygame.Rect(0, 0, 200, 150), - line_spacing=line_spacing) + line_spacing=line_spacing, + default_font_data=default_font_data) layout_row = TextBoxLayoutRow(row_start_x=0, row_start_y=0, row_index=0, @@ -291,10 +345,16 @@ def test_merge_adjacent_compatible_chunks(self, _init_pygame, default_ui_manager def test_finalise(self, _init_pygame, default_ui_manager: UIManager): input_data = deque([]) line_spacing = 1.25 + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } text_box_layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 200, 300), view_rect=pygame.Rect(0, 0, 200, 150), - line_spacing=line_spacing) + line_spacing=line_spacing, + default_font_data=default_font_data) layout_row = TextBoxLayoutRow(row_start_x=0, row_start_y=0, row_index=0, @@ -334,10 +394,16 @@ def test_finalise(self, _init_pygame, default_ui_manager: UIManager): def test_set_default_text_colour(self, _init_pygame, default_ui_manager: UIManager): input_data = deque([]) line_spacing = 1.25 + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } text_box_layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 200, 300), view_rect=pygame.Rect(0, 0, 200, 150), - line_spacing=line_spacing) + line_spacing=line_spacing, + default_font_data=default_font_data) layout_row = TextBoxLayoutRow(row_start_x=0, row_start_y=0, row_index=0, @@ -367,10 +433,16 @@ def test_set_default_text_colour(self, _init_pygame, default_ui_manager: UIManag def test_toggle_cursor(self, _init_pygame, default_ui_manager: UIManager): input_data = deque([]) line_spacing = 1.25 + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } text_box_layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 200, 300), view_rect=pygame.Rect(0, 0, 200, 150), - line_spacing=line_spacing) + line_spacing=line_spacing, + default_font_data=default_font_data) layout_row = TextBoxLayoutRow(row_start_x=0, row_start_y=0, row_index=0, @@ -408,10 +480,16 @@ def test_toggle_cursor(self, _init_pygame, default_ui_manager: UIManager): def test_clear(self, _init_pygame, default_ui_manager: UIManager): input_data = deque([]) line_spacing = 1.25 + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } text_box_layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 200, 300), view_rect=pygame.Rect(0, 0, 200, 150), - line_spacing=line_spacing) + line_spacing=line_spacing, + default_font_data=default_font_data) layout_row = TextBoxLayoutRow(row_start_x=0, row_start_y=0, row_index=0, @@ -437,13 +515,19 @@ def test_clear(self, _init_pygame, default_ui_manager: UIManager): layout_row.clear() assert layout_surface.get_at((18, 18)) == pygame.Color('#00000000') - def test_set_cursor_from_click_pos(self): + def test_set_cursor_from_click_pos(self, default_ui_manager: UIManager): input_data = deque([]) line_spacing = 1.25 + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } text_box_layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 200, 300), view_rect=pygame.Rect(0, 0, 200, 150), - line_spacing=line_spacing) + line_spacing=line_spacing, + default_font_data=default_font_data) layout_row = TextBoxLayoutRow(row_start_x=0, row_start_y=0, row_index=0, @@ -490,13 +574,19 @@ def test_set_cursor_from_click_pos(self): assert layout_row.left == 0 assert layout_row.cursor_index == 0 - def test_set_cursor_position(self): + def test_set_cursor_position(self, default_ui_manager: UIManager): input_data = deque([]) line_spacing = 1.25 + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } text_box_layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 200, 300), view_rect=pygame.Rect(0, 0, 200, 150), - line_spacing=line_spacing) + line_spacing=line_spacing, + default_font_data=default_font_data) layout_row = TextBoxLayoutRow(row_start_x=0, row_start_y=0, row_index=0, @@ -551,10 +641,16 @@ def test_insert_text(self, _init_pygame, default_ui_manager: UIManager): input_data = deque([]) line_spacing = 1.25 + default_font = default_ui_manager.get_theme().get_font_dictionary().get_default_font() + default_font_data = {"font": default_font, + "font_colour": pygame.Color("#FFFFFF"), + "bg_colour": pygame.Color("#00000000") + } text_box_layout = TextBoxLayout(input_data_queue=input_data, layout_rect=pygame.Rect(0, 0, 200, 300), view_rect=pygame.Rect(0, 0, 200, 150), - line_spacing=line_spacing) + line_spacing=line_spacing, + default_font_data=default_font_data) layout_row = TextBoxLayoutRow(row_start_x=0, row_start_y=0, row_index=0, diff --git a/tests/test_elements/test_ui_text_entry_line.py b/tests/test_elements/test_ui_text_entry_line.py index 21ef484e..896b84a5 100644 --- a/tests/test_elements/test_ui_text_entry_line.py +++ b/tests/test_elements/test_ui_text_entry_line.py @@ -922,9 +922,16 @@ def test_redraw_selected_text_different_them(self, _init_pygame): def test_update(self, _init_pygame): pygame.display.init() - manager = UIManager((800, 600), os.path.join("tests", "data", - "themes", - "ui_text_entry_line_non_default.json")) + + class MouselessManager(UIManager): + fixed_mouse_pos = (0, 0) + + def _update_mouse_position(self): + self.mouse_position = MouselessManager.fixed_mouse_pos + + manager = MouselessManager((800, 600), os.path.join("tests", "data", + "themes", + "ui_text_entry_line_non_default.json")) text_entry = UITextEntryLine(relative_rect=pygame.Rect(100, 100, 200, 30), manager=manager) @@ -933,8 +940,8 @@ def test_update(self, _init_pygame): assert text_entry.alive() assert not manager.text_input_hovered - manager.mouse_position = (150, 115) - text_entry.update(0.01) + MouselessManager.fixed_mouse_pos = (200, 115) + manager.update(0.01) assert manager.text_input_hovered text_entry.kill() diff --git a/tests/test_elements/test_ui_window.py b/tests/test_elements/test_ui_window.py index a772eb45..f0a9e43f 100644 --- a/tests/test_elements/test_ui_window.py +++ b/tests/test_elements/test_ui_window.py @@ -299,16 +299,16 @@ def test_get_top_layer(self, _init_pygame, default_ui_manager, manager=default_ui_manager, container=window) - assert window.get_top_layer() == 4 + assert window.get_top_layer() == 5 window.update(0.05) - assert window.get_top_layer() == 5 # This used to be 6, maybe it should be - drop downs? + assert window.get_top_layer() == 7 # This used to be 6, maybe it should be - drop downs? def test_change_layer(self, _init_pygame, default_ui_manager, _display_surface_return_none): window = UIWindow(pygame.Rect(0, 0, 200, 200), window_display_title="Test Window", manager=default_ui_manager) - assert window.get_top_layer() == 4 + assert window.get_top_layer() == 5 window.change_layer(10) diff --git a/tests/test_ui_manager.py b/tests/test_ui_manager.py index 4dd2c7d7..81512e0b 100644 --- a/tests/test_ui_manager.py +++ b/tests/test_ui_manager.py @@ -8,6 +8,7 @@ from pygame_gui.ui_manager import UIManager from pygame_gui.core import UIAppearanceTheme, UIWindowStack from pygame_gui.elements.ui_button import UIButton +from pygame_gui.elements.ui_text_entry_line import UITextEntryLine from pygame_gui.elements.ui_text_box import UITextBox from pygame_gui.windows.ui_message_window import UIMessageWindow from pygame_gui.elements.ui_window import UIWindow @@ -340,9 +341,12 @@ def test_set_active_cursor(self, _init_pygame, _display_surface_return_none): assert manager._active_cursor == pygame.cursors.Cursor(pygame.SYSTEM_CURSOR_HAND) def test_set_hovering_text(self, _init_pygame, _display_surface_return_none): - manager = UIManager((800, 600)) + class MouselessManager(UIManager): + def _update_mouse_position(self): + self.mouse_position = (400, 15) + manager = MouselessManager((800, 600)) - manager.set_text_input_hovered(True) + text_entry = UITextEntryLine(pygame.Rect(0, 0, 800, 30), manager=manager) manager.update(0.5) assert manager._active_cursor == pygame.cursors.Cursor(pygame.SYSTEM_CURSOR_IBEAM) From b8973eed1962dde9c9ac005c8bb071af1ccd8f85 Mon Sep 17 00:00:00 2001 From: Dan Lawrence Date: Sun, 30 Oct 2022 12:08:49 +0000 Subject: [PATCH 13/14] Add basic test from text box to entry box --- tests/test_elements/test_ui_text_entry_box.py | 740 ++++++++++++++++++ 1 file changed, 740 insertions(+) create mode 100644 tests/test_elements/test_ui_text_entry_box.py diff --git a/tests/test_elements/test_ui_text_entry_box.py b/tests/test_elements/test_ui_text_entry_box.py new file mode 100644 index 00000000..40f87d82 --- /dev/null +++ b/tests/test_elements/test_ui_text_entry_box.py @@ -0,0 +1,740 @@ +import os + +import pygame +import pytest + +import pygame_gui +from pygame_gui.elements.ui_text_entry_box import UITextEntryBox +from pygame_gui.ui_manager import UIManager + +from pygame_gui import UITextEffectType + + +class TestUITextEntryBox: + + def test_creation(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none): + default_ui_manager.preload_fonts([{"name": "fira_code", "size:": 14, "style": "bold"}, + {"name": "fira_code", "size:": 14, "style": "italic"}]) + text_box = UITextEntryBox( + initial_text="Some text in a bold box using colours and " + "styles.", + relative_rect=pygame.Rect(100, 100, 200, 300), + manager=default_ui_manager) + assert text_box.image is not None + + def test_set_text(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none): + default_ui_manager.preload_fonts([{"name": "fira_code", "size:": 14, "style": "bold"}, + {"name": "fira_code", "size:": 14, "style": "italic"}]) + text_box = UITextEntryBox( + initial_text="Some text in a bold box using colours and " + "styles.", + relative_rect=pygame.Rect(100, 100, 200, 300), + manager=default_ui_manager) + assert text_box.image is not None + + text_box.set_text("Changed text") + assert text_box.image is not None + + def test_clear(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none): + default_ui_manager.preload_fonts([{"name": "fira_code", "size:": 14, "style": "bold"}, + {"name": "fira_code", "size:": 14, "style": "italic"}]) + text_box = UITextEntryBox( + initial_text="Some text in a bold box using colours and " + "styles.", + relative_rect=pygame.Rect(100, 100, 200, 300), + manager=default_ui_manager) + assert text_box.image is not None + + text_box.clear() + assert text_box.image is not None + + def test_creation_grow_to_fit_width(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none): + default_ui_manager.preload_fonts([{"name": "fira_code", "size:": 14, "style": "bold"}, + {"name": "fira_code", "size:": 14, "style": "italic"}]) + text_box = UITextEntryBox( + initial_text="Some text in a box not using colours and styles. Hey hey hey, what is this?" + "More text padding this out a little. Well OK.", + relative_rect=pygame.Rect(100, 100, -1, 50), + manager=default_ui_manager) + assert text_box.image is not None and text_box.rect.width == 984 + + def test_creation_and_rebuild_with_scrollbar(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none): + default_ui_manager.preload_fonts([{'name': 'fira_code', 'html_size': 4.5, 'style': 'bold'}, + {'name': 'fira_code', 'html_size': 4.5, 'style': 'regular'}, + {'name': 'fira_code', 'html_size': 2, 'style': 'regular'}, + {'name': 'fira_code', 'html_size': 2, 'style': 'italic'}, + {'name': 'fira_code', 'html_size': 6, 'style': 'bold'}, + {'name': 'fira_code', 'html_size': 6, 'style': 'regular'}, + {'name': 'fira_code', 'html_size': 6, 'style': 'bold_italic'}, + {'name': 'fira_code', 'html_size': 4, 'style': 'bold'}, + {'name': 'fira_code', 'html_size': 4, 'style': 'italic'}, + {'name': 'fira_code', 'html_size': 2, 'style': 'bold'}, + {'name': 'fira_code', 'html_size': 2, 'style': 'bold_italic'}]) + text_box = UITextEntryBox(initial_text='' + '
Lorem' + '


' + 'ipsum dolor sit amet
,' + ' consectetur adipiscing elit. in a flibb de ' + 'dib do ' + '

rub a la clob slip the perry tin fo glorp yip dorp' + 'skorp si pork flum de dum be ' + 'dung, ' + 'slob be robble glurp destination flum' + ' kin slum.

' + 'Ram slim gordo, fem tulip squirrel slippers save socks certainly.
' + 'Vestibulum in commodo me tellus in nisi finibus a sodales.
' + 'Vestibulum' + 'hendrerit mi sed nulla scelerisque, posuere ' + 'ullamcorper ' + 'sem pulvinar.' + 'Nulla at pulvinar a odio, a dictum dolor.
Maecenas at ' + 'tellus a' + 'tortor. a
' + '' + '' + '' + 'In bibendum orci et velit
gravida lacinia.

' + 'In hac a habitasse to platea dictumst.
' + 'Vivamus I interdum mollis lacus nec porttitor.' + '
Morbi ' + 'accumsan, lectus at ' + 'tincidunt to dictum, neque erat tristique erat, ' + 'sed a tempus for nunc dolor in nibh.
' + 'Suspendisse in viverra dui fringilla dolor laoreet, sit amet on ' + 'pharetra a ante ' + 'sollicitudin.
' + '

' + 'consectetur adipiscing elit. in a
' + 'Vestibulum in commodo me tellus in nisi finibus a sodales.
' + 'Vestibulum hendrerit mi sed nulla scelerisque, ' + 'posuere ullamcorper sem pulvinar. ' + 'Nulla at pulvinar a odio, a dictum dolor.
' + 'Maecenas at tellus a tortor. a
' + 'In bibendum orci et velit
gravida lacinia.

' + 'In hac a habitasse to platea dictumst.
' + 'Vivamus I interdum mollis lacus nec ' + 'porttitor.
Morbi ' + 'accumsan, lectus at' + 'tincidunt to dictum, neque erat tristique erat, ' + 'sed a tempus for nunc dolor in nibh.
' + 'Suspendisse in viverra dui fringilla dolor laoreet, sit amet on ' + 'pharetra a ante ' + 'sollicitudin.
', + relative_rect=pygame.Rect(100, 100, 200, 300), + manager=default_ui_manager) + + text_box.rebuild() + + assert text_box.image is not None + + def test_create_too_narrow_textbox_for_font(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none): + # narrow text boxes are fine with no dashes between split words + + text_box = UITextEntryBox(initial_text='la la LA LA LAL LAL ALALA' + 'LLALAALALA ALALA ALAL ALA' + 'LAALA ALALA ALALA AAaal aa' + 'ALALAa laalal alalal alala' + 'alalalala alalalalalal alal' + 'alalalala alala alalala ala' + 'alalalalal lalal alalalal al', + relative_rect=pygame.Rect(100, 100, 5, 50), + manager=default_ui_manager) + + assert text_box.image is not None + + def test_kill(self, _init_pygame: None, default_ui_manager: UIManager, + _display_surface_return_none): + default_ui_manager.preload_fonts([{'name': 'fira_code', 'html_size': 4.5, 'style': 'bold'}, + {'name': 'fira_code', 'html_size': 4.5, 'style': 'regular'}, + {'name': 'fira_code', 'html_size': 2, 'style': 'regular'}, + {'name': 'fira_code', 'html_size': 2, 'style': 'italic'}, + {'name': 'fira_code', 'html_size': 6, 'style': 'bold'}, + {'name': 'fira_code', 'html_size': 6, 'style': 'regular'}, + {'name': 'fira_code', 'html_size': 6, 'style': 'bold_italic'}, + {'name': 'fira_code', 'html_size': 4, 'style': 'bold'}, + {'name': 'fira_code', 'html_size': 4, 'style': 'italic'}, + {'name': 'fira_code', 'html_size': 2, 'style': 'bold'}, + {'name': 'fira_code', 'html_size': 2, 'style': 'bold_italic'}]) + text_box = UITextEntryBox(initial_text='' + '
Lorem' + '


' + 'ipsum dolor sit amet
,' + ' consectetur adipiscing elit. in a flibb de ' + 'dib do ' + 'rub a la clob slip the perry tin fo glorp yip dorp' + 'skorp si pork flum de dum be dung, slob be robble glurp destination flum ' + 'kin slum. ' + 'Ram slim gordo, fem tulip squirrel slippers save socks certainly.
' + 'Vestibulum in commodo me tellus in nisi finibus a sodales.
' + 'Vestibulum' + 'hendrerit mi sed nulla scelerisque, posuere ' + 'ullamcorper ' + 'sem pulvinar.' + 'Nulla at pulvinar a odio, a dictum dolor.
Maecenas at ' + 'tellus a' + 'tortor. a
' + 'In bibendum orci et velit
gravida lacinia.

' + 'In hac a habitasse to platea dictumst.
' + 'Vivamus I interdum mollis lacus nec porttitor.' + '
Morbi ' + 'accumsan, lectus at ' + 'tincidunt to dictum, neque erat tristique erat, ' + 'sed a tempus for nunc dolor in nibh.
' + 'Suspendisse in viverra dui fringilla dolor laoreet, sit amet on ' + 'pharetra a ante ' + 'sollicitudin.
' + '

' + 'consectetur adipiscing elit. in a
' + 'Vestibulum in commodo me tellus in nisi finibus a sodales.
' + 'Vestibulum hendrerit mi sed nulla scelerisque, ' + 'posuere ullamcorper sem pulvinar. ' + 'Nulla at pulvinar a odio, a dictum dolor.
' + 'Maecenas at tellus a tortor. a
' + 'In bibendum orci et velit
gravida lacinia.

' + 'In hac a habitasse to platea dictumst.
' + 'Vivamus I interdum mollis lacus nec ' + 'porttitor.
Morbi ' + 'accumsan, lectus at' + 'tincidunt to dictum, neque erat tristique erat, ' + 'sed a tempus for nunc dolor in nibh.
' + 'Suspendisse in viverra dui fringilla dolor laoreet, sit amet ' + 'on pharetra a ante ' + 'sollicitudin.
', + relative_rect=pygame.Rect(100, 100, 200, 300), + manager=default_ui_manager) + + assert len(default_ui_manager.get_root_container().elements) == 3 + assert len(default_ui_manager.get_sprite_group().sprites()) == 7 + assert default_ui_manager.get_sprite_group().sprites() == [default_ui_manager.get_root_container(), + text_box, + text_box.scroll_bar, + text_box.scroll_bar.button_container, + text_box.scroll_bar.top_button, + text_box.scroll_bar.bottom_button, + text_box.scroll_bar.sliding_button] + text_box.kill() + text_box.update(0.01) + assert len(default_ui_manager.get_root_container().elements) == 0 + assert len(default_ui_manager.get_sprite_group().sprites()) == 1 + assert default_ui_manager.get_sprite_group().sprites() == [default_ui_manager.get_root_container()] + + def test_on_fresh_drawable_shape_ready(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none): + text_box = UITextEntryBox(initial_text='la la LA LA LAL LAL ALALA' + 'LLALAALALA ALALA ALAL ALA' + 'LAALA ALALA ALALA AAaal aa' + 'ALALAa laalal alalal alala' + 'alalalala alalalalalal alal' + 'alalalala alala alalala ala' + 'alalalalal lalal alalalal al', + relative_rect=pygame.Rect(100, 100, 100, 100), + manager=default_ui_manager) + text_box.on_fresh_drawable_shape_ready() + + assert text_box.background_surf is not None + + def test_set_position_with_scrollbar(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none): + text_box = UITextEntryBox(initial_text='la la LA LA LAL LAL ALALA' + 'LLALAALALA ALALA ALAL ALA' + 'LAALA ALALA ALALA AAaal aa' + 'ALALAa laalal alalal alala' + 'alalalala alalalalalal alal' + 'alalalala alala alalala ala' + 'alalalalal lalal alalalal al', + relative_rect=pygame.Rect(100, 100, 100, 100), + manager=default_ui_manager) + text_box.set_position(pygame.math.Vector2(0.0, 0.0)) + assert text_box.rect.topleft == (0, 0) + + default_ui_manager.process_events(pygame.event.Event(pygame.MOUSEBUTTONDOWN, + {'button': 1, + 'pos': (92, 8)})) + # if we successfully clicked on the moved text box scroll bar then this button should be True + assert text_box.scroll_bar.top_button.held is True + + def test_set_relative_position_with_scrollbar(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none): + text_box = UITextEntryBox(initial_text='la la LA LA LAL LAL ALALA' + 'LLALAALALA ALALA ALAL ALA' + 'LAALA ALALA ALALA AAaal aa' + 'ALALAa laalal alalal alala' + 'alalalala alalalalalal alal' + 'alalalala alala alalala ala' + 'alalalalal lalal alalalal al', + relative_rect=pygame.Rect(100, 100, 150, 100), + manager=default_ui_manager) + text_box.set_relative_position(pygame.math.Vector2(0.0, 0.0)) + assert text_box.rect.topleft == (0, 0) + + default_ui_manager.process_events(pygame.event.Event(pygame.MOUSEBUTTONDOWN, + {'button': 1, + 'pos': (142, 8)})) + # if we successfully clicked on the moved text box + # scroll bar then this button should be True + assert text_box.scroll_bar.top_button.held is True + + def test_update_with_scrollbar(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none): + text_box = UITextEntryBox(initial_text='la la LA LA LAL LAL ALALA' + 'LLALAALALA ALALA ALAL ALA' + 'LAALA ALALA ALALA AAaal aa' + 'ALALAa laalal alalal alala' + 'alalalala alalalalalal alal' + 'alalalala alala ' + 'alalala ala' + 'alalalalal lalal alalalal al', + relative_rect=pygame.Rect(100, 100, 150, 100), + manager=default_ui_manager) + text_box.scroll_bar.has_moved_recently = True + text_box.update(5.0) + assert text_box.image is not None + + text_box.set_dimensions((0, 0)) + text_box.update(0.02) + + text_box.set_dimensions((150, 200)) + text_box.update(0.02) + text_box.update(0.02) + text_box.update(0.2) + + # trigger rebuild from update + text_box.should_trigger_full_rebuild = True + text_box.full_rebuild_countdown = 0.0 + text_box.update(0.01) + assert text_box.image is not None + + def test_update_without_scrollbar(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none: None): + text_box = UITextEntryBox(initial_text='lalaLAlalala', + relative_rect=pygame.Rect(0, 0, 150, 100), + manager=default_ui_manager) + default_ui_manager.mouse_position = (20, 15) + text_box.update(5.0) + default_ui_manager.mouse_position = (200, 200) + text_box.update(5.0) + + assert text_box.image is not None + + def test_redraw_from_text_block_with_scrollbar(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none): + text_box = UITextEntryBox(initial_text='la la LA LA LAL LAL ALALA' + 'LLALAALALA ALALA ALAL ALA' + 'LAALA ALALA ALALA AAaal aa' + 'ALALAa laalal alalal alala' + 'alalalala alalalalalal alal' + 'alalalala alala ' + 'alalala ala' + 'alalalalal lalal alalalal al', + relative_rect=pygame.Rect(100, 100, 150, 100), + manager=default_ui_manager) + text_box.redraw_from_text_block() + + assert text_box.image is not None + + text_box.rect.width = 0 + text_box.redraw_from_text_block() # should return + + assert text_box.image is not None + + def test_redraw_from_text_block_no_scrollbar(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none): + text_box = UITextEntryBox(initial_text='la la LA LA', + relative_rect=pygame.Rect(100, 100, 150, 100), + manager=default_ui_manager) + text_box.redraw_from_text_block() + assert text_box.image is not None + + def test_redraw_from_chunks_with_scrollbar(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none): + text_box = UITextEntryBox(initial_text='la la LA LA LAL LAL ALALA' + 'LLALAALALA ALALA ALAL ALA' + 'LAALA ALALA ALALA AAaal aa' + 'ALALAa laalal alalal alala' + 'alalalala alalalalalal alal' + 'alalalala alala ' + 'alalala ala' + 'alalalalal lalal alalalal al', + relative_rect=pygame.Rect(100, 100, 150, 100), + manager=default_ui_manager) + text_box.redraw_from_chunks() + assert text_box.image is not None + + def test_full_redraw_with_scrollbar(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none): + text_box = UITextEntryBox(initial_text='la la LA LA LAL LAL ALALA' + 'LLALAALALA ALALA ALAL ALA' + 'LAALA ALALA ALALA AAaal aa' + 'ALALAa laalal alalal alala' + 'alalalala alalalalalal alal' + 'alalalala alala ' + 'alalala ala' + 'alalalalal lalal alalalal al', + relative_rect=pygame.Rect(100, 100, 150, 100), + manager=default_ui_manager) + text_box.full_redraw() + assert text_box.image is not None + + def test_select_with_scrollbar(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none): + text_box = UITextEntryBox(initial_text='la la LA LA LAL LAL ALALA' + 'LLALAALALA ALALA ALAL ALA' + 'LAALA ALALA ALALA AAaal aa' + 'ALALAa laalal alalal alala' + 'alalalala alalalalalal alal' + 'alalalala alala ' + 'alalala ala' + 'alalalalal lalal alalalal al', + relative_rect=pygame.Rect(100, 100, 150, 100), + manager=default_ui_manager) + default_ui_manager.set_focus_set(text_box.get_focus_set()) + assert text_box.scroll_bar.is_focused is True + + def test_set_active_effect_fade_in(self, _init_pygame: None, default_ui_manager: UIManager, + _display_surface_return_none: None): + text_box = UITextEntryBox(initial_text='la la LA LA LAL LAL ALALA' + 'LLALAALALA ALALA ALAL ALA' + 'LAALA ALALA ALALA AAaal aa' + 'ALALAa laalal alalal alala' + 'alalalala alalalalalal alal' + 'alalalala alala ' + 'alalala ala' + 'alalalalal lalal alalalal al', + relative_rect=pygame.Rect(100, 100, 150, 100), + manager=default_ui_manager) + text_box.set_active_effect(pygame_gui.TEXT_EFFECT_FADE_IN) + text_box.update(5.0) + assert type(text_box.active_text_effect) == pygame_gui.core.text.FadeInEffect + + def test_set_active_effect_fade_out(self, _init_pygame: None, default_ui_manager: UIManager, + _display_surface_return_none: None): + text_box = UITextEntryBox(initial_text='la la LA LA LAL LAL ALALA' + 'LLALAALALA ALALA ALAL ALA' + 'LAALA ALALA ALALA AAaal aa' + 'ALALAa laalal alalal alala' + 'alalalala alalalalalal alal' + 'alalalala alala ' + 'alalala ala' + 'alalalalal lalal alalalal al', + relative_rect=pygame.Rect(100, 100, 150, 100), + manager=default_ui_manager) + text_box.set_active_effect(pygame_gui.TEXT_EFFECT_FADE_OUT) + text_box.update(5.0) + assert type(text_box.active_text_effect) == pygame_gui.core.text.FadeOutEffect + + def test_set_active_effect_invalid(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none): + text_box = UITextEntryBox(initial_text='la la LA LA LAL LAL ALALA' + 'LLALAALALA ALALA ALAL ALA' + 'LAALA ALALA ALALA AAaal aa' + 'ALALAa laalal alalal alala' + 'alalalala alalalalalal alal' + 'alalalala alala ' + 'alalala ala' + 'alalalalal lalal alalalal al', + relative_rect=pygame.Rect(100, 100, 150, 100), + manager=default_ui_manager) + with pytest.warns(UserWarning, match="Unsupported effect name"): + text_box.set_active_effect("ghost_text") + + def test_set_active_effect_none(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none): + text_box = UITextEntryBox(initial_text='la la LA LA LAL LAL ALALA' + 'LLALAALALA ALALA ALAL ALA' + 'LAALA ALALA ALALA AAaal aa' + 'ALALAa laalal alalal alala' + 'alalalala alalalalalal alal' + 'alalalala alala ' + 'alalala ala' + 'alalalalal lalal alalalal al', + relative_rect=pygame.Rect(100, 100, 150, 100), + manager=default_ui_manager) + + text_box.set_active_effect(None) + assert text_box.active_text_effect is None + + def test_set_active_effect_with_word_split(self, _init_pygame: None, + _display_surface_return_none): + manager = UIManager((800, 600), os.path.join("tests", "data", + "themes", "ui_text_box_non_default.json")) + manager.preload_fonts([{"name": "fira_code", "point_size": 10, "style": "regular"}, + {'name': 'fira_code', 'point_size': 10, 'style': 'bold'}, + {"name": "fira_code", "point_size": 10, "style": "italic"}, + {"name": "fira_code", "point_size": 10, "style": "bold_italic"}]) + htm_text_block_2 = UITextEntryBox(initial_text='' + 'Hey, What the heck!' + '

' + 'This is some
text in a different box,' + ' hooray for variety - ' + 'if you want then you should put a ring upon it. ' + 'What if we do a really long word?' + ' ' + 'derp FALALALALALALALXALALALXALALALALAAPaaaaarp' + ' gosh', + relative_rect=pygame.Rect((0, 0), (250, 200)), + manager=manager, + object_id="#text_box_2") + htm_text_block_2.set_active_effect(pygame_gui.TEXT_EFFECT_TYPING_APPEAR) + htm_text_block_2.active_text_effect.text_changed = True + htm_text_block_2.update(5.0) + htm_text_block_2.update(5.0) + assert type(htm_text_block_2.active_text_effect) == pygame_gui.core.text.TypingAppearEffect + + def test_process_event_mouse_buttons_with_scrollbar(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none: None): + text_box = UITextEntryBox(initial_text='la la LA LA LAL LAL ALALA' + 'LLALAALALA ALALA ALAL ALA' + 'LAALA ALALA ALALA AAaal aa' + 'ALALAa laalal alalal alala' + 'alalalala alalalalalal alal' + 'alalalala alala ' + 'alalala ala' + 'alalalalal lalal alalalal al', + relative_rect=pygame.Rect(0, 0, 150, 100), + manager=default_ui_manager) + + processed_down_event = text_box.process_event(pygame.event.Event(pygame.MOUSEBUTTONDOWN, + {'button': 1, 'pos': (30, 15)})) + text_box.process_event(pygame.event.Event(pygame.MOUSEBUTTONUP, {'button': 1, 'pos': (80, 15)})) + + assert processed_down_event is True + + def test_process_event_mouse_buttons_no_scrollbar(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none: None): + text_box = UITextEntryBox(initial_text='alalaadads', + relative_rect=pygame.Rect(0, 0, 150, 100), + manager=default_ui_manager) + + processed_down_event = text_box.process_event(pygame.event.Event(pygame.MOUSEBUTTONDOWN, + {'button': 1, 'pos': (20, 15)})) + text_box.process_event(pygame.event.Event(pygame.MOUSEBUTTONUP, {'button': 1, 'pos': (20, 15)})) + + assert processed_down_event is True + + def test_rebuild(self, _init_pygame, + default_ui_manager: UIManager, + _display_surface_return_none): + text_box = UITextEntryBox(initial_text='hello', + relative_rect=pygame.Rect(0, 0, 150, 100), + manager=default_ui_manager) + + text_box.rebuild() + + assert text_box.image is not None + + # try with 0 height rect, should return + text_box.rect.height = 0 + text_box.rebuild() + + assert text_box.image is not None + + def test_rebuild_from_theme_data_non_default(self, _init_pygame, + _display_surface_return_none): + manager = UIManager((800, 600), os.path.join("tests", "data", + "themes", "ui_text_box_non_default.json")) + + manager.preload_fonts([{"name": "fira_code", "size:": 14, "style": "bold"}, + {"name": "fira_code", "size:": 14, "style": "italic"}]) + text_box = UITextEntryBox(initial_text="Some " + "text " + "in a bold box using " + "colours and styles.", + relative_rect=pygame.Rect(100, 100, 200, 300), + manager=manager) + text_box.redraw_from_chunks() + text_box.full_redraw() + assert text_box.image is not None + + @pytest.mark.filterwarnings("ignore:Invalid value") + @pytest.mark.filterwarnings("ignore:Colour hex code") + def test_rebuild_from_theme_data_bad_values(self, _init_pygame, + _display_surface_return_none): + manager = UIManager((800, 600), os.path.join("tests", "data", + "themes", "ui_text_box_bad_values.json")) + + manager.preload_fonts([{"name": "fira_code", "size:": 14, "style": "bold"}, + {"name": "fira_code", "size:": 14, "style": "italic"}]) + text_box = UITextEntryBox( + initial_text="Some text in a bold box using " + "colours and styles.", + relative_rect=pygame.Rect(100, 100, 200, 300), + manager=manager) + assert text_box.image is not None + + def test_set_dimensions(self, _init_pygame, default_ui_manager, + _display_surface_return_none): + text_box = UITextEntryBox(initial_text='la la LA LA LAL LAL ALALA' + 'LLALAALALA ALALA ALAL ALA' + 'LAALA ALALA ALALA AAaal aa' + 'ALALAa laalal alalal alala' + 'alalalala alalalalalal alal' + 'alalalala alala ' + 'alalala ala' + 'alalalalal lalal alalalal al' + 'al alalalal lfed alal alal alal al' + 'ala lalalal lasda lal a lalalal slapl' + 'alalala lal la blop lal alal aferlal al', + relative_rect=pygame.Rect(0, 0, 150, 100), + manager=default_ui_manager) + + text_box.set_dimensions((200, 80)) + + # try to click on the slider + default_ui_manager.process_events(pygame.event.Event(pygame.MOUSEBUTTONDOWN, + {'button': 1, + 'pos': (195, 75)})) + # if we successfully clicked on the moved slider then this button should be True + assert text_box.scroll_bar.bottom_button.held is True + + def test_disable(self, _init_pygame: None, default_ui_manager: UIManager, + _display_surface_return_none: None): + text_box = UITextEntryBox(initial_text='la la LA LA LAL LAL ALALA' + 'LLALAALALA ALALA ALAL ALA' + 'LAALA ALALA ALALA AAaal aa' + 'ALALAa laalal alalal alala' + 'alalalala alalalalalal alal' + 'alalalala alala ' + 'alalala ala' + 'alalalalal lalal alalalal al', + relative_rect=pygame.Rect(0, 0, 150, 100), + manager=default_ui_manager) + + text_box.disable() + + assert text_box.is_enabled is False + assert text_box.scroll_bar.is_enabled is False + + # process a mouse button down event + text_box.scroll_bar.bottom_button.process_event( + pygame.event.Event(pygame.MOUSEBUTTONDOWN, + {'button': 1, 'pos': text_box.scroll_bar.bottom_button.rect.center})) + + text_box.scroll_bar.update(0.1) + + # process a mouse button up event + text_box.scroll_bar.bottom_button.process_event( + pygame.event.Event(pygame.MOUSEBUTTONUP, + {'button': 1, 'pos': text_box.scroll_bar.bottom_button.rect.center})) + + assert text_box.scroll_bar.scroll_position == 0.0 + + def test_enable(self, _init_pygame: None, default_ui_manager: UIManager, + _display_surface_return_none: None): + text_box = UITextEntryBox(initial_text='la la LA LA LAL LAL ALALA' + 'LLALAALALA ALALA ALAL ALA' + 'LAALA ALALA ALALA AAaal aa' + 'ALALAa laalal alalal alala' + 'alalalala alalalalalal alal' + 'alalalala alala ' + 'alalala ala' + 'alalalalal lalal alalalal al', + relative_rect=pygame.Rect(0, 0, 150, 100), + manager=default_ui_manager) + + text_box.disable() + text_box.enable() + + assert text_box.is_enabled is True + assert text_box.scroll_bar.is_enabled is True + + # process a mouse button down event + text_box.scroll_bar.bottom_button.process_event( + pygame.event.Event(pygame.MOUSEBUTTONDOWN, + {'button': 1, 'pos': text_box.scroll_bar.bottom_button.rect.center})) + + text_box.scroll_bar.update(0.1) + + # process a mouse button up event + text_box.scroll_bar.bottom_button.process_event( + pygame.event.Event(pygame.MOUSEBUTTONUP, + {'button': 1, 'pos': text_box.scroll_bar.bottom_button.rect.center})) + + assert text_box.scroll_bar.scroll_position != 0.0 + + def test_on_locale_changed(self, _init_pygame, default_ui_manager, _display_surface_return_none): + text_box = UITextEntryBox(initial_text='la la LA LA LAL LAL ALALA' + 'LLALAALALA ALALA ALAL ALA' + 'LAALA ALALA ALALA AAaal aa' + 'ALALAa laalal alalal alala' + 'alalalala alalalalalal alal' + 'alalalala alala ' + 'alalala ala' + 'alalalalal lalal alalalal al' + 'al alalalal lfed alal alal alal al' + 'ala lalalal lasda lal a lalalal slapl' + 'alalala lal la blop lal alal aferlal al', + relative_rect=pygame.Rect(0, 0, 150, 100), + manager=default_ui_manager) + + default_ui_manager.set_locale('fr') + + default_ui_manager.set_locale('ja') + + assert text_box.image is not None + + def test_text_owner_interface(self, _init_pygame, default_ui_manager, + _display_surface_return_none): + text_box = UITextEntryBox(initial_text='la la LA LA LAL LAL ALALA' + 'LLALAALALA ALALA ALAL ALA' + 'LAALA ALALA ALALA AAaal aa' + 'ALALAa laalal alalal alala' + 'alalalala alalalalalal alal' + 'alalalala alala ' + 'alalala ala' + 'alalalalal lalal alalalal al' + 'al alalalal lfed alal alal alal al' + 'ala lalalal lasda lal a lalalal slapl' + 'alalala lal la blop lal alal aferlal al', + relative_rect=pygame.Rect(0, 0, 150, 100), + manager=default_ui_manager) + + # these do nothing right now for full block effects + text_box.set_text_offset_pos((0, 0)) + text_box.set_text_rotation(0) + text_box.set_text_scale(0) + + assert text_box.image is not None + + def test_pre_parsing(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none): + text_box = UITextEntryBox(initial_text="Some text\n" + "On different lines with backslash n\n" + "Done.", + relative_rect=pygame.Rect(100, 100, 500, 300), + manager=default_ui_manager) + assert text_box.image is not None + assert len(text_box.text_box_layout.layout_rows) == 3 + + +if __name__ == '__main__': + pytest.console_main() From 61aab4acc8d1b64a25ed86dc112be0275a171f9c Mon Sep 17 00:00:00 2001 From: Dan Lawrence Date: Sun, 30 Oct 2022 12:43:16 +0000 Subject: [PATCH 14/14] Add basic tests from entry line to entry box --- pygame_gui/elements/ui_text_entry_box.py | 19 +- tests/test_elements/test_ui_text_entry_box.py | 729 +++++++++++++++++- 2 files changed, 744 insertions(+), 4 deletions(-) diff --git a/pygame_gui/elements/ui_text_entry_box.py b/pygame_gui/elements/ui_text_entry_box.py index 25f93306..5471a995 100644 --- a/pygame_gui/elements/ui_text_entry_box.py +++ b/pygame_gui/elements/ui_text_entry_box.py @@ -18,7 +18,7 @@ class UITextEntryBox(UITextBox): def __init__(self, relative_rect: Union[Rect, Tuple[int, int, int, int]], - initial_text: str, + initial_text: str = "", manager: Optional[IUIManagerInterface] = None, container: Optional[IContainerLikeInterface] = None, parent_element: Optional[UIElement] = None, @@ -56,6 +56,20 @@ def __init__(self, self.vertical_cursor_movement = False self.last_horiz_cursor_index = 0 + def get_text(self) -> str: + """ + Gets the text in the entry box element. + + :return: A string. + + """ + return self.html_text + + def set_text(self, html_text: str, *, text_kwargs: Optional[Dict[str, str]] = None): + super().set_text(html_text, text_kwargs=text_kwargs) + self.edit_position = len(self.html_text) + self.cursor_has_moved_recently = True + @property def select_range(self): """ @@ -201,7 +215,7 @@ def process_event(self, event: Event) -> bool: if self.is_enabled and self.is_focused and event.type == KEYDOWN: if self._process_keyboard_shortcut_event(event): consumed_event = True - if self._process_action_key_event(event): + elif self._process_action_key_event(event): consumed_event = True elif self._process_text_entry_key(event): consumed_event = True @@ -404,6 +418,7 @@ def _process_text_entry_key(self, event: Event) -> bool: self.edit_position += 1 self.redraw_from_text_block() self.cursor_has_moved_recently = True + consumed_event = True elif hasattr(event, 'unicode'): character = event.unicode diff --git a/tests/test_elements/test_ui_text_entry_box.py b/tests/test_elements/test_ui_text_entry_box.py index 40f87d82..9fb32ea7 100644 --- a/tests/test_elements/test_ui_text_entry_box.py +++ b/tests/test_elements/test_ui_text_entry_box.py @@ -2,12 +2,14 @@ import pygame import pytest +import platform import pygame_gui + +from pygame_gui.core.utility import clipboard_paste, clipboard_copy from pygame_gui.elements.ui_text_entry_box import UITextEntryBox from pygame_gui.ui_manager import UIManager - -from pygame_gui import UITextEffectType +from pygame_gui.core import UIContainer class TestUITextEntryBox: @@ -647,6 +649,26 @@ def test_disable(self, _init_pygame: None, default_ui_manager: UIManager, assert text_box.scroll_bar.scroll_position == 0.0 + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=default_ui_manager) + + text_entry.focus() + text_entry.disable() + + assert text_entry.is_enabled is False + # process a mouse button down event + processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, + {'key': pygame.K_d, + 'mod': 0, + 'unicode': 'd'})) + + text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, {'key': pygame.K_a, 'mod': 0, + 'unicode': 'a'})) + text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, {'key': pygame.K_n, 'mod': 0, + 'unicode': 'n'})) + + assert processed_key_event is False and text_entry.get_text() == '' + def test_enable(self, _init_pygame: None, default_ui_manager: UIManager, _display_surface_return_none: None): text_box = UITextEntryBox(initial_text='la la LA LA LAL LAL ALALA' @@ -680,6 +702,29 @@ def test_enable(self, _init_pygame: None, default_ui_manager: UIManager, assert text_box.scroll_bar.scroll_position != 0.0 + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=default_ui_manager) + + text_entry.disable() + + text_entry.focus() + text_entry.enable() + + assert text_entry.is_enabled is True + text_entry.focus() + # process a mouse button down event + processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, + {'key': pygame.K_d, + 'mod': 0, + 'unicode': 'd'})) + + text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, {'key': pygame.K_a, 'mod': 0, + 'unicode': 'a'})) + text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, {'key': pygame.K_n, 'mod': 0, + 'unicode': 'n'})) + + assert processed_key_event is True and text_entry.get_text() == 'dan' + def test_on_locale_changed(self, _init_pygame, default_ui_manager, _display_surface_return_none): text_box = UITextEntryBox(initial_text='la la LA LA LAL LAL ALALA' 'LLALAALALA ALALA ALAL ALA' @@ -734,6 +779,686 @@ def test_pre_parsing(self, _init_pygame: None, manager=default_ui_manager) assert text_box.image is not None assert len(text_box.text_box_layout.layout_rows) == 3 + + def test_rebuild_select_area_1(self, _init_pygame, default_ui_manager, + _display_surface_return_none): + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=default_ui_manager) + + text_entry.set_text("GOLD") + text_entry.select_range = [0, 2] + text_entry.rebuild() + + assert text_entry.image is not None + + def test_set_text_rebuild_select_area_2(self, _init_pygame, + _display_surface_return_none): + manager = UIManager((800, 600), os.path.join("tests", "data", + "themes", + "ui_text_entry_line_non_default.json")) + + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=manager) + + text_entry.set_text("GOLDEN GOD") + text_entry.select_range = [4, 7] + text_entry.rebuild() + + assert text_entry.image is not None + + def test_set_text_rebuild_select_area_3(self, _init_pygame): + manager = UIManager((800, 600), os.path.join("tests", "data", + "themes", + "ui_text_entry_line_non_default_2.json")) + + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=manager) + + text_entry.set_text("GOLDEN GOD") + text_entry.select_range = [4, 7] + text_entry.rebuild() + + assert text_entry.image is not None + + @pytest.mark.filterwarnings("ignore:Invalid value") + @pytest.mark.filterwarnings("ignore:Colour hex code") + def test_set_text_rebuild_select_area_3(self, _init_pygame): + manager = UIManager((800, 600), os.path.join("tests", "data", + "themes", + "ui_text_entry_line_bad_values.json")) + + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=manager) + + text_entry.set_text("GOLD") + text_entry.select_range = [0, 2] + text_entry.rebuild() + + assert text_entry.image is not None + + def test_focus(self, _init_pygame, default_ui_manager, _display_surface_return_none): + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=default_ui_manager) + + text_entry.focus() + + assert text_entry.image is not None + + def test_unfocus(self, _init_pygame, default_ui_manager, _display_surface_return_none): + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=default_ui_manager) + + text_entry.unfocus() + + assert text_entry.image is not None + + def test_process_event_text_entered_success(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none: None): + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=default_ui_manager) + + text_entry.focus() + + processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, + {'key': pygame.K_d, + 'mod': 0, + 'unicode': 'd'})) + + text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, {'key': pygame.K_a, 'mod': 0, + 'unicode': 'a'})) + text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, {'key': pygame.K_n, 'mod': 0, + 'unicode': 'n'})) + + assert processed_key_event and text_entry.get_text() == 'dan' + + def test_process_event_text_entered_with_select_range(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none: None): + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=default_ui_manager) + + text_entry.set_text('Hours and hours of fun writing tests') + text_entry.focus() + text_entry.select_range = [1, 9] + + # process a mouse button down event + processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, + {'key': pygame.K_o, + 'mod': 0, + 'unicode': 'o'})) + + assert (processed_key_event is True and + text_entry.get_text() == 'Ho hours of fun writing tests') + + def test_process_event_text_ctrl_c(self, _init_pygame: None, + _display_surface_return_none: None): + manager = UIManager((800, 600), os.path.join("tests", "data", + "themes", + "ui_text_entry_line_non_default_2.json")) + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=manager) + + text_entry.set_text('dan') + text_entry.focus() + text_entry.select_range = [0, 3] + + processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, + {'key': pygame.K_c, + 'mod': pygame.KMOD_CTRL, + 'unicode': 'c'})) + text_entry.cursor_on = True + + assert processed_key_event and clipboard_paste() == 'dan' + + def test_process_event_text_ctrl_v(self, _init_pygame: None, default_ui_manager: UIManager, + _display_surface_return_none: None): + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=default_ui_manager) + + text_entry.set_text('dan') + text_entry.focus() + text_entry.select_range = [1, 3] + + text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, {'key': pygame.K_c, + 'mod': pygame.KMOD_CTRL, + 'unicode': 'c'})) + text_entry.select_range = [0, 0] + text_entry.edit_position = 3 + processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, + {'key': pygame.K_v, + 'mod': pygame.KMOD_CTRL, + 'unicode': 'v'})) + + assert processed_key_event and text_entry.get_text() == 'danan' + + def test_process_event_text_ctrl_v_nothing(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none: None): + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=default_ui_manager) + + clipboard_copy('') + text_entry.set_text('dan') + text_entry.focus() + text_entry.select_range = [0, 0] + processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, + {'key': pygame.K_v, + 'mod': pygame.KMOD_CTRL, + 'unicode': 'v'})) + + assert processed_key_event and text_entry.get_text() == 'dan' + + def test_process_event_ctrl_v_at_limit(self, _init_pygame: None, default_ui_manager: UIManager, + _display_surface_return_none: None): + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=default_ui_manager) + + text_entry.set_text('dan') + text_entry.focus() + text_entry.length_limit = 3 + text_entry.select_range = [0, 3] + + text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, {'key': pygame.K_c, + 'mod': pygame.KMOD_CTRL, + 'unicode': 'c'})) + + text_entry.set_text('bob') + text_entry.focus() + text_entry.select_range = [0, 3] + text_entry.edit_position = 0 + processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, + {'key': pygame.K_v, + 'mod': pygame.KMOD_CTRL, + 'unicode': 'v'})) + + assert processed_key_event and text_entry.get_text() == 'dan' + + def test_process_event_text_ctrl_v_select_range(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none: None): + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=default_ui_manager) + + text_entry.set_text('dan') + text_entry.focus() + text_entry.select_range = [1, 3] + + text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, {'key': pygame.K_c, + 'mod': pygame.KMOD_CTRL, + 'unicode': 'c'})) + text_entry.select_range = [0, 3] + processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, + {'key': pygame.K_v, + 'mod': pygame.KMOD_CTRL, + 'unicode': 'v'})) + + assert processed_key_event + + if not platform.system().upper() == "LINUX": + # copy and paste is unreliable on linux, this part of the test fails fairly regularly there + assert text_entry.get_text() == 'an' + + def test_process_event_text_ctrl_a(self, _init_pygame: None, default_ui_manager: UIManager, + _display_surface_return_none: None): + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=default_ui_manager) + + text_entry.set_text('dan') + text_entry.focus() + + processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, + {'key': pygame.K_a, + 'mod': pygame.KMOD_CTRL, + 'unicode': 'a'})) + + text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, {'key': pygame.K_c, + 'mod': pygame.KMOD_CTRL, + 'unicode': 'c'})) + + text_entry.select_range = [0, 0] + text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, {'key': pygame.K_v, + 'mod': pygame.KMOD_CTRL, + 'unicode': 'v'})) + + assert processed_key_event and text_entry.get_text() == 'dandan' + + def test_process_event_text_ctrl_x(self, _init_pygame: None, default_ui_manager: UIManager, + _display_surface_return_none: None): + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=default_ui_manager) + + text_entry.set_text('dan') + text_entry.focus() + + processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, + {'key': pygame.K_a, + 'mod': pygame.KMOD_CTRL, + 'unicode': 'a'})) + + text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, {'key': pygame.K_x, + 'mod': pygame.KMOD_CTRL, + 'unicode': 'x'})) + + text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, {'key': pygame.K_v, + 'mod': pygame.KMOD_CTRL, + 'unicode': 'v'})) + + assert processed_key_event and clipboard_paste() == 'dan' + + def test_process_event_mouse_buttons(self, _init_pygame: None, default_ui_manager: UIManager, + _display_surface_return_none: None): + text_entry = UITextEntryBox(relative_rect=pygame.Rect(0, 0, 200, 30), + manager=default_ui_manager) + + text_entry.set_text('dan is amazing') + processed_down_event = text_entry.process_event(pygame.event.Event(pygame.MOUSEBUTTONDOWN, + {'button': 1, + 'pos': (30, 15)})) + processed_up_event = text_entry.process_event(pygame.event.Event(pygame.MOUSEBUTTONUP, + {'button': 1, + 'pos': (80, 15)})) + + assert processed_down_event + assert processed_up_event + assert text_entry.select_range == [3, 9] + + def test_process_event_mouse_button_double_click(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none: None): + text_entry = UITextEntryBox(relative_rect=pygame.Rect(0, 0, 200, 30), + manager=default_ui_manager) + + text_entry.set_text('dan is amazing') + processed_down_event = text_entry.process_event(pygame.event.Event(pygame.MOUSEBUTTONDOWN, + {'button': 1, + 'pos': (90, 15)})) + processed_up_event = text_entry.process_event(pygame.event.Event(pygame.MOUSEBUTTONDOWN, + {'button': 1, + 'pos': (90, 15)})) + + assert (processed_down_event and processed_up_event and text_entry.select_range == [7, 14]) + + text_entry.set_text('') + processed_down_event = text_entry.process_event(pygame.event.Event(pygame.MOUSEBUTTONDOWN, + {'button': 1, + 'pos': (90, 15)})) + processed_up_event = text_entry.process_event(pygame.event.Event(pygame.MOUSEBUTTONDOWN, + {'button': 1, + 'pos': (90, 15)})) + + assert (processed_down_event and processed_up_event and text_entry.select_range == [0, 0]) + + def test_process_event_mouse_button_double_click_in_empty_space( + self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none: None): + text_entry = UITextEntryBox(relative_rect=pygame.Rect(0, 0, 200, 30), + manager=default_ui_manager) + + text_entry.set_text(' dan') + processed_down_event = text_entry.process_event(pygame.event.Event(pygame.MOUSEBUTTONDOWN, + {'button': 1, + 'pos': (90, 15)})) + processed_up_event = text_entry.process_event(pygame.event.Event(pygame.MOUSEBUTTONDOWN, + {'button': 1, + 'pos': (90, 15)})) + + assert (processed_down_event and processed_up_event and text_entry.select_range == [0, 0]) + + def test_process_event_mouse_button_double_click_first_word(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none: None): + text_entry = UITextEntryBox(relative_rect=pygame.Rect(0, 0, 200, 30), + manager=default_ui_manager) + + text_entry.set_text('dan is amazing') + processed_down_event = text_entry.process_event( + pygame.event.Event(pygame.MOUSEBUTTONDOWN, {'button': pygame.BUTTON_LEFT, + 'pos': (15, 15)})) + processed_up_event = text_entry.process_event( + pygame.event.Event(pygame.MOUSEBUTTONDOWN, {'button': pygame.BUTTON_LEFT, + 'pos': (15, 15)})) + + assert (processed_down_event and processed_up_event and text_entry.select_range == [0, 3]) + + def test_process_event_mouse_button_up_outside(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none: None): + text_entry = UITextEntryBox(relative_rect=pygame.Rect(0, 0, 200, 30), + manager=default_ui_manager) + + text_entry.set_text('dan is amazing') + processed_down_event = text_entry.process_event(pygame.event.Event(pygame.MOUSEBUTTONDOWN, + {'button': 1, + 'pos': (30, 15)})) + text_entry.process_event(pygame.event.Event(pygame.MOUSEBUTTONUP, {'button': 1, + 'pos': (80, 50)})) + + assert processed_down_event and text_entry.selection_in_progress is False + + def test_process_event_text_return(self, _init_pygame: None, default_ui_manager: UIManager, + _display_surface_return_none: None): + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=default_ui_manager) + + text_entry.set_text('dan') + text_entry.focus() + + processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, + {'key': pygame.K_RETURN})) + + assert processed_key_event + + def test_process_event_text_right(self, _init_pygame: None, default_ui_manager: UIManager, + _display_surface_return_none: None): + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=default_ui_manager) + + text_entry.set_text('dan') + text_entry.focus() + + processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, + {'key': pygame.K_RIGHT})) + + assert processed_key_event + + def test_process_event_text_right_actually_move(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none: None): + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=default_ui_manager) + + text_entry.set_text('dan') + text_entry.edit_position = 2 + text_entry.focus() + + processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, + {'key': pygame.K_RIGHT})) + + assert processed_key_event + + def test_process_event_text_left(self, _init_pygame: None, default_ui_manager: UIManager, + _display_surface_return_none: None): + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=default_ui_manager) + + text_entry.set_text('dan') + text_entry.focus() + assert text_entry.edit_position == 3 + + processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, + {'key': pygame.K_LEFT})) + + assert processed_key_event + assert text_entry.edit_position == 2 + + def test_process_event_home(self, _init_pygame: None, default_ui_manager: UIManager, + _display_surface_return_none: None): + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=default_ui_manager) + + text_entry.set_text('dan') + text_entry.focus() + text_entry.select_range = [0, 2] + + processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, + {'key': pygame.K_HOME})) + + assert processed_key_event + assert text_entry.select_range == [0, 0] + assert text_entry.edit_position == 0 + + def test_process_event_end(self, _init_pygame: None, default_ui_manager: UIManager, + _display_surface_return_none: None): + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=default_ui_manager) + + text_entry.set_text('dan') + text_entry.focus() + text_entry.select_range = [0, 2] + + processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, + {'key': pygame.K_END})) + + assert processed_key_event + assert text_entry.select_range == [0, 0] + assert text_entry.edit_position == 3 + + def test_process_event_text_right_select_range(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none: None): + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=default_ui_manager) + + text_entry.set_text('dan') + text_entry.focus() + text_entry.select_range = [0, 2] + + processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, + {'key': pygame.K_RIGHT})) + + assert processed_key_event + + def test_process_event_text_left_select_range(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none: None): + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=default_ui_manager) + + text_entry.set_text('dan') + text_entry.focus() + text_entry.select_range = [0, 2] + + processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, + {'key': pygame.K_LEFT})) + + assert processed_key_event + + def test_process_event_delete_select_range(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none: None): + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=default_ui_manager) + + text_entry.set_text('dan') + text_entry.focus() + text_entry.select_range = [0, 2] + + processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, + {'key': pygame.K_DELETE})) + + assert processed_key_event + + def test_process_event_delete(self, _init_pygame: None, default_ui_manager: UIManager, + _display_surface_return_none: None): + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=default_ui_manager) + + text_entry.set_text('dan') + text_entry.focus() + text_entry.edit_position = 1 + + processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN, + {'key': pygame.K_DELETE})) + + assert processed_key_event + + def test_process_event_backspace(self, _init_pygame: None, default_ui_manager: UIManager, + _display_surface_return_none: None): + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=default_ui_manager) + + text_entry.set_text('dan') + text_entry.focus() + text_entry.edit_position = 2 + text_entry.start_text_offset = 1 + + processed_key_event = text_entry.process_event( + pygame.event.Event(pygame.KEYDOWN, {'key': pygame.K_BACKSPACE})) + + assert processed_key_event + + def test_process_event_backspace_select_range(self, _init_pygame: None, + default_ui_manager: UIManager, + _display_surface_return_none: None): + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=default_ui_manager) + + text_entry.set_text('dan') + text_entry.focus() + text_entry.select_range = [1, 3] + + processed_key_event = text_entry.process_event( + pygame.event.Event(pygame.KEYDOWN, {'key': pygame.K_BACKSPACE})) + + assert processed_key_event + + @pytest.mark.filterwarnings("ignore:Invalid value") + @pytest.mark.filterwarnings("ignore:Colour hex code") + def test_redraw_selected_text(self, _init_pygame): + manager = UIManager((800, 600), os.path.join("tests", "data", + "themes", + "ui_text_entry_line_bad_values.json")) + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=manager) + + text_entry.set_text("Yellow su") + text_entry.select_range = [3, 8] + text_entry.start_text_offset = 500 + + def test_redraw_selected_text_different_theme(self, _init_pygame): + manager = UIManager((800, 600), os.path.join("tests", "data", + "themes", + "ui_text_entry_line_non_default_2.json")) + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=manager) + + text_entry.set_text("Yellow su") + text_entry.select_range = [3, 9] + + def test_update(self, _init_pygame): + pygame.display.init() + + class MouselessManager(UIManager): + fixed_mouse_pos = (0, 0) + + def _update_mouse_position(self): + self.mouse_position = MouselessManager.fixed_mouse_pos + + manager = MouselessManager((800, 600), os.path.join("tests", "data", + "themes", + "ui_text_entry_line_non_default.json")) + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=manager) + + text_entry.update(0.01) + + assert text_entry.alive() + assert not manager.text_input_hovered + + MouselessManager.fixed_mouse_pos = (200, 115) + manager.update(0.01) + assert manager.text_input_hovered + + text_entry.kill() + + text_entry.update(0.01) + + assert not text_entry.alive() + + def test_update_after_click(self, _init_pygame, _display_surface_return_none: None, default_ui_manager): + manager = UIManager((800, 600), os.path.join("tests", "data", + "themes", + "ui_text_entry_line_non_default.json")) + text_entry = UITextEntryBox(relative_rect=pygame.Rect(0, 0, 200, 30), + manager=manager) + + text_entry.set_text('Wow testing is great so amazing') + text_entry.focus() + + text_entry.process_event(pygame.event.Event(pygame.MOUSEBUTTONDOWN, {'button': 1, + 'pos': (30, 15)})) + default_ui_manager.mouse_position = (70, 15) + + text_entry.update(0.01) + + def test_update_newline_after_click(self, _init_pygame, _display_surface_return_none: None, default_ui_manager): + manager = UIManager((800, 600), os.path.join("tests", "data", + "themes", + "ui_text_entry_line_non_default.json")) + text_entry = UITextEntryBox(relative_rect=pygame.Rect(0, 0, 200, 30), + manager=manager) + + text_entry.set_text('Wow testing is great so amazing\n\n') + text_entry.focus() + + text_entry.process_event(pygame.event.Event(pygame.MOUSEBUTTONDOWN, {'button': 1, + 'pos': (30, 15)})) + default_ui_manager.mouse_position = (70, 15) + + text_entry.update(0.01) + + def test_update_after_long_wait(self, _init_pygame): + pygame.display.init() + manager = UIManager((800, 600), os.path.join("tests", "data", + "themes", + "ui_text_entry_line_non_default.json")) + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=manager) + + text_entry.update(0.01) + text_entry.update(5.0) + + def test_update_cursor_blink(self, _init_pygame, _display_surface_return_none: None): + manager = UIManager((800, 600), os.path.join("tests", "data", + "themes", + "ui_text_entry_line_non_default.json")) + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=manager) + + text_entry.focus() + text_entry.cursor_blink_delay_after_moving_acc = 10.0 + text_entry.update(0.01) + text_entry.blink_cursor_time_acc = 10.0 + text_entry.update(0.01) + text_entry.blink_cursor_time_acc = 10.0 + text_entry.update(0.01) + + @pytest.mark.filterwarnings("ignore:Invalid value") + @pytest.mark.filterwarnings("ignore:Colour hex code") + def test_rebuild_from_theme_data_bad_values(self, _init_pygame): + manager = UIManager((800, 600), os.path.join("tests", "data", + "themes", + "ui_text_entry_line_bad_values.json")) + + text_entry = UITextEntryBox(relative_rect=pygame.Rect(100, 100, 200, 30), + manager=manager) + assert text_entry.image is not None + + def test_set_position(self, _init_pygame, default_ui_manager, _display_surface_return_none): + test_container = UIContainer(relative_rect=pygame.Rect(100, 100, 300, 60), + manager=default_ui_manager) + text_entry = UITextEntryBox(relative_rect=pygame.Rect(0, 0, 150, 30), + container=test_container, + manager=default_ui_manager) + + text_entry.set_position((150.0, 30.0)) + + assert (text_entry.relative_rect.topleft == (50, -70) and + text_entry.drawable_shape.containing_rect.topleft == (150, 30)) + + def test_set_relative_position(self, _init_pygame, default_ui_manager, + _display_surface_return_none): + test_container = UIContainer(relative_rect=pygame.Rect(50, 50, 300, 250), + manager=default_ui_manager) + text_entry = UITextEntryBox(relative_rect=pygame.Rect(0, 0, 150, 30), + container=test_container, + manager=default_ui_manager) + + text_entry.set_relative_position((50.0, 30.0)) + + assert text_entry.rect.topleft == (100, 80) if __name__ == '__main__':