Repository: MyreMylar/pygame_gui
Pull request #460 (2023-05-28 20:12:24)

Text selection improvements
I believe this satisfies https://github.com/MyreMylar/pygame_gui/issues/459.

Two main notes.

1. I'd like to hear whether you agree on changing HOME/END behavior in UITextEntryBox to change to be start/end of current line (to support multi-line text case).

2. Currently, it's possible for my changes to create a situation where `select_range` becomes `[x, x]` for `x != 0`, but my understanding of the intent is that this means no selection and should probably be automatically set to `[0, 0]` in the setter. I wasn't able to get that to work without disrupting other tests, so I'm probably missing additional context. In any case, at least I don't see any new problems created by this situation and the tests pass.


Patch:
diff --git a/pygame_gui/elements/ui_text_entry_box.py b/pygame_gui/elements/ui_text_entry_box.py
index 9c60804..342da46 100644
--- a/pygame_gui/elements/ui_text_entry_box.py
+++ b/pygame_gui/elements/ui_text_entry_box.py
@@ -4,7 +4,7 @@ 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, TEXTINPUT
-from pygame import KMOD_META, KMOD_CTRL, K_a, K_x, K_c, K_v
+from pygame import KMOD_SHIFT, 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
@@ -333,7 +333,140 @@ class UITextEntryBox(UITextBox):
 
         """
         consumed_event = False
-        if event.key == K_LEFT:
+        # modded versions of LEFT and RIGHT must go first otherwise the simple
+        # cases will absorb the events
+        if event.key == K_HOME or (event.key == K_LEFT
+            and (event.mod & KMOD_CTRL or event.mod & KMOD_META)
+            ):
+            try:
+                next_pos = self.edit_position - self.html_text[:self.edit_position][::-1].index("\n")
+            except ValueError:
+                next_pos = 0
+            # include case when shift held down to select everything to start
+            # of current line.
+            if abs(self.select_range[0] - self.select_range[1]) > 0:
+                if event.mod & KMOD_SHIFT:
+                    if self.edit_position == self.select_range[1]:
+                        # undo selection to right, create to left
+                        self.select_range = [next_pos, self.select_range[0]]
+                    else:
+                        # extend left
+                        self.select_range = [next_pos, self.select_range[1]]
+                else:
+                    self.select_range = [0, 0]
+            else:
+                if event.mod & KMOD_SHIFT:
+                    self.select_range = [next_pos, self.edit_position]
+                else:
+                    self.select_range = [0, 0]
+            self.edit_position = next_pos
+            self.cursor_has_moved_recently = True
+            consumed_event = True
+        elif event.key == K_END or (event.key == K_RIGHT
+            and (event.mod & KMOD_CTRL or event.mod & KMOD_META)
+            ):
+            try:
+                next_pos = self.edit_position + self.html_text[self.edit_position:].index("\n")
+            except ValueError:
+                next_pos = len(self.html_text)
+            # include case when shift held down to select everything to end
+            # of current line.
+            if abs(self.select_range[0] - self.select_range[1]) > 0:
+                if event.mod & KMOD_SHIFT:
+                    if self.edit_position == self.select_range[0]:
+                        # undo selection to left, create to right
+                        self.select_range = [self.select_range[1], next_pos]
+                    else:
+                        # extend right
+                        self.select_range = [self.select_range[0], next_pos]
+                else:
+                    self.select_range = [0, 0]
+            else:
+                if event.mod & KMOD_SHIFT:
+                    self.select_range = [self.edit_position, next_pos]
+                else:
+                    self.select_range = [0, 0]
+            self.edit_position = next_pos
+            self.cursor_has_moved_recently = True
+            consumed_event = True
+        elif event.key == K_LEFT and event.mod & KMOD_SHIFT:
+            # keyboard-based selection
+            if abs(self.select_range[0] - self.select_range[1]) > 0:
+                # existing selection, so edit_position should correspond to one
+                # end of it
+                if self.edit_position == self.select_range[0]:
+                    # try to extend to the left
+                    self.select_range = [max(0, self.edit_position - 1),
+                                         self.select_range[1]]
+                elif self.edit_position == self.select_range[1]:
+                    # reduce to the left
+                    self.select_range = [self.select_range[0],
+                                         max(0, self.edit_position - 1)]
+            else:
+                self.select_range = [max(0, self.edit_position - 1),
+                                     self.edit_position]
+            if self.edit_position > 0:
+                self.edit_position -= 1
+                self.cursor_has_moved_recently = True
+            consumed_event = True
+        elif event.key == K_RIGHT and event.mod & KMOD_SHIFT:
+            # keyboard-based selection
+            if abs(self.select_range[0] - self.select_range[1]) > 0:
+                if self.edit_position == self.select_range[1]:
+                    # try to extend to the right
+                    self.select_range = [self.select_range[0],
+                                         min(len(self.html_text), self.edit_position + 1)]
+                elif self.edit_position == self.select_range[0]:
+                    # reduce to the right
+                    self.select_range = [min(len(self.html_text), self.edit_position + 1),
+                                         self.select_range[1]]
+            else:
+                self.select_range = [self.edit_position, min(len(self.html_text),
+                                                             self.edit_position + 1)]
+            if self.edit_position < len(self.html_text):
+                self.edit_position += 1
+                self.cursor_has_moved_recently = True
+            consumed_event = True
+        elif event.key == K_UP and event.mod & KMOD_SHIFT:
+            # keyboard-based selection
+            new_pos = self.text_box_layout.get_cursor_pos_move_up_one_row(self.last_horiz_cursor_index)
+            if abs(self.select_range[0] - self.select_range[1]) > 0:
+                # existing selection, so edit_position should correspond to one
+                # end of it
+                if self.edit_position == self.select_range[0]:
+                    # try to extend to the left
+                    self.select_range = [max(0, new_pos),
+                                         self.select_range[1]]
+                elif self.edit_position == self.select_range[1]:
+                    # reduce to the left
+                    self.select_range = [self.select_range[0],
+                                         max(0, new_pos)]
+            else:
+                self.select_range = [max(0, new_pos),
+                                     self.edit_position]
+            if self.edit_position > 0:
+                self.edit_position = new_pos
+                self.cursor_has_moved_recently = True
+        elif event.key == K_DOWN and event.mod & KMOD_SHIFT:
+            # keyboard-based selection
+            new_pos = self.text_box_layout.get_cursor_pos_move_down_one_row(self.last_horiz_cursor_index)
+            if abs(self.select_range[0] - self.select_range[1]) > 0:
+                if self.edit_position == self.select_range[1]:
+                    # try to extend to the right
+                    self.select_range = [self.select_range[0],
+                                                 min(len(self.html_text), new_pos)]
+                elif self.edit_position == self.select_range[0]:
+                    # reduce to the right
+                    self.select_range = [min(len(self.html_text), new_pos),
+                                                 self.select_range[1]]
+            else:
+                self.select_range = [self.edit_position, min(len(self.html_text),
+                                                                     new_pos)]
+            if self.edit_position < len(self.html_text):
+                self.edit_position = new_pos
+                self.cursor_has_moved_recently = True
+            consumed_event = True
+        elif 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]
@@ -606,4 +739,3 @@ class UITextEntryBox(UITextBox):
     @staticmethod
     def convert_all_line_endings_to_unix(input_str: str):
         return input_str.replace('\r\n', '\n').replace('\r', '\n')
-
diff --git a/pygame_gui/elements/ui_text_entry_line.py b/pygame_gui/elements/ui_text_entry_line.py
index 01fad4f..e56255f 100644
--- a/pygame_gui/elements/ui_text_entry_line.py
+++ b/pygame_gui/elements/ui_text_entry_line.py
@@ -579,7 +579,91 @@ class UITextEntryLine(UIElement):
 
         """
         consumed_event = False
-        if event.key == pygame.K_LEFT:
+        # modded versions of LEFT and RIGHT must go first otherwise the simple
+        # cases will absorb the events
+        if event.key == pygame.K_HOME or (event.key == pygame.K_LEFT
+            and (event.mod & pygame.KMOD_CTRL or event.mod & pygame.KMOD_META)
+            ):
+            # include case when shift held down to select everything to start
+            # of current line.
+            if abs(self.select_range[0] - self.select_range[1]) > 0:
+                if event.mod & pygame.KMOD_SHIFT:
+                    if self.edit_position == self.select_range[1]:
+                        # undo selection to right, create to left
+                        self.select_range = [0, self.select_range[0]]
+                    else:
+                        # extend left
+                        self.select_range = [0, self.select_range[1]]
+                else:
+                    self.select_range = [0, 0]
+            else:
+                if event.mod & pygame.KMOD_SHIFT:
+                    self.select_range = [0, self.edit_position]
+                else:
+                    self.select_range = [0, 0]
+            self.edit_position = 0
+            self.cursor_has_moved_recently = True
+            consumed_event = True
+        elif event.key == pygame.K_END or (event.key == pygame.K_RIGHT
+            and (event.mod & pygame.KMOD_CTRL or event.mod & pygame.KMOD_META)
+            ):
+            if abs(self.select_range[0] - self.select_range[1]) > 0:
+                if event.mod & pygame.KMOD_SHIFT:
+                    if self.edit_position == self.select_range[0]:
+                        # undo selection to left, create to right
+                        self.select_range = [self.select_range[1], len(self.text)]
+                    else:
+                        # extend right
+                        self.select_range = [self.select_range[0], len(self.text)]
+                else:
+                    self.select_range = [0, 0]
+            else:
+                if event.mod & pygame.KMOD_SHIFT:
+                    self.select_range = [self.edit_position, len(self.text)]
+                else:
+                    self.select_range = [0, 0]
+            self.edit_position = len(self.text)
+            self.cursor_has_moved_recently = True
+            consumed_event = True
+        elif event.key == pygame.K_LEFT and event.mod & pygame.KMOD_SHIFT:
+            # keyboard-based selection
+            if abs(self.select_range[0] - self.select_range[1]) > 0:
+                # existing selection, so edit_position should correspond to one
+                # end of it
+                if self.edit_position == self.select_range[0]:
+                    # try to extend to the left
+                    self.select_range = [max(0, self.edit_position - 1),
+                                         self.select_range[1]]
+                elif self.edit_position == self.select_range[1]:
+                    # reduce to the left
+                    self.select_range = [self.select_range[0], max(0,
+                                                    self.edit_position - 1)]
+            else:
+                self.select_range = [max(0, self.edit_position - 1),
+                                     self.edit_position]
+            if self.edit_position > 0:
+                self.edit_position -= 1
+                self.cursor_has_moved_recently = True
+            consumed_event = True
+        elif event.key == pygame.K_RIGHT and event.mod & pygame.KMOD_SHIFT:
+            # keyboard-based selection
+            if abs(self.select_range[0] - self.select_range[1]) > 0:
+                if self.edit_position == self.select_range[1]:
+                    # try to extend to the right
+                    self.select_range = [self.select_range[0],
+                                         min(len(self.text), self.edit_position + 1)]
+                elif self.edit_position == self.select_range[0]:
+                    # reduce to the right
+                    self.select_range = [min(len(self.text), self.edit_position + 1),
+                                         self.select_range[1]]
+            else:
+                self.select_range = [self.edit_position, min(len(self.text),
+                                                    self.edit_position + 1)]
+            if self.edit_position < len(self.text):
+                self.edit_position += 1
+                self.cursor_has_moved_recently = True
+            consumed_event = True
+        elif event.key == pygame.K_LEFT:
             if abs(self.select_range[0] - self.select_range[1]) > 0:
                 self.edit_position = min(self.select_range[0], self.select_range[1])
                 self.select_range = [0, 0]


Test patch:
diff --git a/tests/test_elements/test_ui_text_entry_box.py b/tests/test_elements/test_ui_text_entry_box.py
index 9b1fced..254a622 100644
--- a/tests/test_elements/test_ui_text_entry_box.py
+++ b/tests/test_elements/test_ui_text_entry_box.py
@@ -775,7 +775,7 @@ class TestUITextEntryBox:
                                   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),
@@ -1148,7 +1148,8 @@ class TestUITextEntryBox:
         text_entry.focus()
 
         processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN,
-                                                                          {'key': pygame.K_RIGHT}))
+                                                                          {'key': pygame.K_RIGHT,
+                                                                           'mod': 0}))
 
         assert processed_key_event
 
@@ -1163,7 +1164,8 @@ class TestUITextEntryBox:
         text_entry.focus()
 
         processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN,
-                                                                          {'key': pygame.K_RIGHT}))
+                                                                          {'key': pygame.K_RIGHT,
+                                                                           'mod': 0}))
 
         assert processed_key_event
 
@@ -1177,7 +1179,8 @@ class TestUITextEntryBox:
         assert text_entry.edit_position == 3
 
         processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN,
-                                                                          {'key': pygame.K_LEFT}))
+                                                                          {'key': pygame.K_LEFT,
+                                                                           'mod': 0}))
 
         assert processed_key_event
         assert text_entry.edit_position == 2
@@ -1192,12 +1195,26 @@ class TestUITextEntryBox:
         text_entry.select_range = [0, 2]
 
         processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN,
-                                                                          {'key': pygame.K_HOME}))
+                                                                          {'key': pygame.K_HOME,
+                                                                           'mod': 0}))
 
         assert processed_key_event
         assert text_entry.select_range == [0, 0]
         assert text_entry.edit_position == 0
 
+        text_entry.set_text('daniel\nmulti-line\ntext')
+        text_entry.edit_position = 3
+        text_entry.focus()
+        text_entry.select_range = [1, 3]
+
+        processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN,
+                                                                          {'key': pygame.K_HOME,
+                                                                           'mod': pygame.KMOD_SHIFT}))
+
+        assert processed_key_event
+        assert text_entry.select_range == [0, 1]
+        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),
@@ -1208,12 +1225,26 @@ class TestUITextEntryBox:
         text_entry.select_range = [0, 2]
 
         processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN,
-                                                                          {'key': pygame.K_END}))
+                                                                          {'key': pygame.K_END,
+                                                                           'mod': 0}))
 
         assert processed_key_event
         assert text_entry.select_range == [0, 0]
         assert text_entry.edit_position == 3
 
+        text_entry.set_text('daniel\nmulti-line\ntext')
+        text_entry.edit_position = 3
+        text_entry.focus()
+        text_entry.select_range = [1, 3]
+
+        processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN,
+                                                                          {'key': pygame.K_END,
+                                                                           'mod': pygame.KMOD_SHIFT}))
+
+        assert processed_key_event
+        assert text_entry.select_range == [1, len("daniel")]
+        assert text_entry.edit_position == len("daniel")
+
     def test_process_event_text_right_select_range(self, _init_pygame: None,
                                                    default_ui_manager: UIManager,
                                                    _display_surface_return_none: None):
@@ -1225,7 +1256,8 @@ class TestUITextEntryBox:
         text_entry.select_range = [0, 2]
 
         processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN,
-                                                                          {'key': pygame.K_RIGHT}))
+                                                                          {'key': pygame.K_RIGHT,
+                                                                           'mod': 0}))
 
         assert processed_key_event
 
@@ -1240,7 +1272,8 @@ class TestUITextEntryBox:
         text_entry.select_range = [0, 2]
 
         processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN,
-                                                                          {'key': pygame.K_LEFT}))
+                                                                          {'key': pygame.K_LEFT,
+                                                                           'mod': 0}))
 
         assert processed_key_event
 
diff --git a/tests/test_elements/test_ui_text_entry_line.py b/tests/test_elements/test_ui_text_entry_line.py
index b7fd453..3aa3f93 100644
--- a/tests/test_elements/test_ui_text_entry_line.py
+++ b/tests/test_elements/test_ui_text_entry_line.py
@@ -651,10 +651,22 @@ class TestUITextEntryLine:
         text_entry.focus()
 
         processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN,
-                                                                          {'key': pygame.K_RIGHT}))
+                                                                          {'key': pygame.K_RIGHT,
+                                                                           'mod': 0}))
 
         assert processed_key_event
 
+        text_entry.edit_position = 1
+        text_entry.select_range = [1, 3]
+        text_entry.focus()
+
+        processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN,
+                                                                          {'key': pygame.K_RIGHT,
+                                                                           'mod': pygame.KMOD_SHIFT}))
+        assert processed_key_event
+        assert text_entry.select_range == [2, 3]
+        assert text_entry.edit_position == 2
+
     def test_process_event_text_right_actually_move(self, _init_pygame: None,
                                                     default_ui_manager: UIManager,
                                                     _display_surface_return_none: None):
@@ -666,7 +678,8 @@ class TestUITextEntryLine:
         text_entry.focus()
 
         processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN,
-                                                                          {'key': pygame.K_RIGHT}))
+                                                                          {'key': pygame.K_RIGHT,
+                                                                           'mod': 0}))
 
         assert processed_key_event
 
@@ -680,7 +693,8 @@ class TestUITextEntryLine:
         assert text_entry.edit_position == 3
 
         processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN,
-                                                                          {'key': pygame.K_LEFT}))
+                                                                          {'key': pygame.K_LEFT,
+                                                                           'mod': 0}))
 
         assert processed_key_event
         assert text_entry.edit_position == 2
@@ -695,7 +709,8 @@ class TestUITextEntryLine:
         text_entry.select_range = [0, 2]
 
         processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN,
-                                                                          {'key': pygame.K_HOME}))
+                                                                          {'key': pygame.K_HOME,
+                                                                           'mod': 0}))
 
         assert processed_key_event
         assert text_entry.select_range == [0, 0]
@@ -711,12 +726,25 @@ class TestUITextEntryLine:
         text_entry.select_range = [0, 2]
 
         processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN,
-                                                                          {'key': pygame.K_END}))
+                                                                          {'key': pygame.K_END,
+                                                                           'mod': 0}))
 
         assert processed_key_event
         assert text_entry.select_range == [0, 0]
         assert text_entry.edit_position == 3
 
+        text_entry.edit_position = 1
+        text_entry.select_range = [1, 3]
+        text_entry.focus()
+
+        processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN,
+                                                                          {'key': pygame.K_END,
+                                                                           'mod': pygame.KMOD_SHIFT}))
+        assert processed_key_event
+        assert text_entry.select_range == [3, 3]
+        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):
@@ -728,7 +756,8 @@ class TestUITextEntryLine:
         text_entry.select_range = [0, 2]
 
         processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN,
-                                                                          {'key': pygame.K_RIGHT}))
+                                                                          {'key': pygame.K_RIGHT,
+                                                                           'mod': 0}))
 
         assert processed_key_event
 
@@ -743,7 +772,8 @@ class TestUITextEntryLine:
         text_entry.select_range = [0, 2]
 
         processed_key_event = text_entry.process_event(pygame.event.Event(pygame.KEYDOWN,
-                                                                          {'key': pygame.K_LEFT}))
+                                                                          {'key': pygame.K_LEFT,
+                                                                           'mod': 0}))
 
         assert processed_key_event