Repository: MyreMylar/pygame_gui (python)

Issue:
A question on UIDropDownMenu/separating entry strings from entry IDs
I'm coming with issue/question heavily inspired by [this SO question I made a while ago for another lib](https://stackoverflow.com/questions/74288080/pysimplegui-listboxes-and-double-key-problem) but for which I don't see as straightforward solution as PySimpleGUI author suggested.

In short, I want to showcase, let's say for this example, 5 options in UIDropDownMenu. For brighter example, let's make it fantasy race picker:
- Human
- Elven
- Dwarven
- Orcish
- Hobbitean

The issue is, I can pass list of strings being those, but I can't pass objects. Why does that matter?  
Because, if I would like to use translateable strings, I can't use anything that give me flexibility - for example, class like this:
```py
class Gender:

    def __init__(self, key: str, mod_id: str):
        self.key    : str = key     # translation key
        self.mod_id : str = mod_id  # mod ID

    def __repr__(self) -> str:
        return langjstring(key=self.key, modtype="stats", modid=self.mod_id)
```
Where I can use language key and object to represent unique entry, but its string representation returns currently selected language translation.  
This way I can compare if currently selected entry is exactly matching, without "reverse translating" (which can get buggy and verbose) or showing only key (which is not really what anyone wants).

I hope it's only me missing some reads in documentation, but if it's a lack of feature in PyGame_GUI, it'd be lovely to get this introduced soon (probably to more than only UIDropDownMenu, anything working on entries), as for now it's the only issue I've found in this awesome library.

Pull request:
Add optional ids to dropdown menu items
fixes #474

Patch:
diff --git a/docs/source/events.rst b/docs/source/events.rst
index 6fe8b0b..0d04c95 100644
--- a/docs/source/events.rst
+++ b/docs/source/events.rst
@@ -190,6 +190,7 @@ Fired when a user selects an option in a drop down menu.
 
  - **'type'** : pygame_gui.UI_DROP_DOWN_MENU_CHANGED,
  - **'text'** : The text of the selected option.
+ - **'selected_option_id'** : The ID of the selected option, if no ids were specified this will be the same as 'text'.
  - **'ui_element'** : The :class:`UIDropDownMenu <pygame_gui.elements.UIDropDownMenu>` that fired this event.
  - **'ui_object_id'** : The most unique ID for the drop down menu that fired this event.
 
diff --git a/pygame_gui/elements/ui_drop_down_menu.py b/pygame_gui/elements/ui_drop_down_menu.py
index 9e6e7ca..ffedcac 100644
--- a/pygame_gui/elements/ui_drop_down_menu.py
+++ b/pygame_gui/elements/ui_drop_down_menu.py
@@ -35,8 +35,8 @@ class UIExpandedDropDownState:
 
     def __init__(self,
                  drop_down_menu_ui: 'UIDropDownMenu',
-                 options_list: List[str],
-                 selected_option: str,
+                 options_list: List[Tuple[str, str]],
+                 selected_option: Tuple[str, str],
                  base_position_rect: Union[pygame.Rect, None],
                  close_button_width: int,
                  expand_direction: Union[str, None],
@@ -47,8 +47,8 @@ class UIExpandedDropDownState:
                  expand_on_option_click):
 
         self.drop_down_menu_ui = drop_down_menu_ui
-        self.options_list = options_list
-        self.selected_option = selected_option
+        self.options_list: List[Tuple[str, str]] = options_list
+        self.selected_option: Tuple[str, str] = selected_option
         self.base_position_rect = base_position_rect
 
         self.expand_direction = expand_direction
@@ -121,16 +121,21 @@ class UIExpandedDropDownState:
         border_and_shadow = (self.drop_down_menu_ui.shadow_width +
                              self.drop_down_menu_ui.border_width)
         self.active_buttons = []
+
+        if self.selected_option[0] == self.selected_option[1]:
+            object_id = ObjectID('#selected_option', '@selected_option')
+        else:
+            object_id = ObjectID(self.selected_option[1], '@selected_option')
         self.selected_option_button = UIButton(pygame.Rect((border_and_shadow, border_and_shadow),
                                                            (self.base_position_rect.width -
                                                             self.close_button_width,
                                                             self.base_position_rect.height)),
-                                               self.selected_option,
+                                               self.selected_option[0],
                                                self.ui_manager,
                                                self.ui_container,
                                                starting_height=2,
                                                parent_element=self.drop_down_menu_ui,
-                                               object_id=ObjectID('#selected_option', None))
+                                               object_id=object_id)
         self.drop_down_menu_ui.join_focus_sets(self.selected_option_button)
         self.active_buttons.append(self.selected_option_button)
 
@@ -269,19 +274,20 @@ class UIExpandedDropDownState:
 
         if (event.type == UI_SELECTION_LIST_NEW_SELECTION and
                 event.ui_element == self.options_selection_list):
-            selection = self.options_selection_list.get_single_selection()
+            selection = self.options_selection_list.get_single_selection(include_object_id=True)
             self.drop_down_menu_ui.selected_option = selection
             self.should_transition = True
 
             # old event - to be removed in 0.8.0
             event_data = {'user_type': OldType(UI_DROP_DOWN_MENU_CHANGED),
-                          'text': self.drop_down_menu_ui.selected_option,
+                          'text': self.drop_down_menu_ui.selected_option[0],
                           'ui_element': self.drop_down_menu_ui,
                           'ui_object_id': self.drop_down_menu_ui.most_specific_combined_id}
             pygame.event.post(pygame.event.Event(pygame.USEREVENT, event_data))
 
             # new event
-            event_data = {'text': self.drop_down_menu_ui.selected_option,
+            event_data = {'text': self.drop_down_menu_ui.selected_option[0],
+                          'selected_option_id': self.drop_down_menu_ui.selected_option[1],
                           'ui_element': self.drop_down_menu_ui,
                           'ui_object_id': self.drop_down_menu_ui.most_specific_combined_id}
             pygame.event.post(pygame.event.Event(UI_DROP_DOWN_MENU_CHANGED, event_data))
@@ -385,7 +391,7 @@ class UIClosedDropDownState:
 
     def __init__(self,
                  drop_down_menu_ui: 'UIDropDownMenu',
-                 selected_option: str,
+                 selected_option: Tuple[str, str],
                  base_position_rect: Union[pygame.Rect, None],
                  open_button_width: int,
                  expand_direction: Union[str, None],
@@ -489,16 +495,20 @@ class UIClosedDropDownState:
         border_and_shadow = (self.drop_down_menu_ui.shadow_width +
                              self.drop_down_menu_ui.border_width)
         self.active_buttons = []
+        if self.selected_option[0] == self.selected_option[1]:
+            object_id = ObjectID('#selected_option', '@selected_option')
+        else:
+            object_id = ObjectID(self.selected_option[1], '@selected_option')
         self.selected_option_button = UIButton(pygame.Rect((border_and_shadow, border_and_shadow),
                                                            (self.base_position_rect.width -
                                                             self.open_button_width,
                                                             self.base_position_rect.height)),
-                                               self.selected_option,
+                                               self.selected_option[0],
                                                self.ui_manager,
                                                self.ui_container,
                                                starting_height=2,
                                                parent_element=self.drop_down_menu_ui,
-                                               object_id='#selected_option',
+                                               object_id=object_id,
                                                visible=self.visible)
         self.drop_down_menu_ui.join_focus_sets(self.selected_option_button)
         self.active_buttons.append(self.selected_option_button)
@@ -656,8 +666,8 @@ class UIDropDownMenu(UIContainer):
     """
 
     def __init__(self,
-                 options_list: List[str],
-                 starting_option: str,
+                 options_list: Union[List[str], List[Tuple[str, str]]],
+                 starting_option: Union[str, Tuple[str, str]],
                  relative_rect: pygame.Rect,
                  manager: Optional[IUIManagerInterface] = None,
                  container: Optional[IContainerLikeInterface] = None,
@@ -681,8 +691,16 @@ class UIDropDownMenu(UIContainer):
                          element_id=['drop_down_menu'])
 
         self.__layer_thickness_including_expansion = 4
-        self.options_list = options_list
-        self.selected_option = starting_option
+        self.options_list = []
+        for option in options_list:
+            if isinstance(option, str):
+                self.options_list.append((option, option))
+            else:
+                self.options_list.append(option)
+        if isinstance(starting_option, str):
+            self.selected_option = (starting_option, starting_option)
+        else:
+            self.selected_option = starting_option
         self.open_button_width = 20
 
         self.expansion_height_limit = expansion_height_limit
@@ -740,27 +758,38 @@ class UIDropDownMenu(UIContainer):
         # ignore passed in value and hardcode to 4
         self.__layer_thickness_including_expansion = 4
 
-    def add_options(self, new_options: List[str]) -> None:
+    def add_options(self, new_options: Union[List[str], List[Tuple[str, str]]]) -> None:
         """
-        Add new options to the drop down. Will close the drop down if it is currently open.
+        Add new options to the drop down. Will close the drop-down if it is currently open.
 
-        In many cases it may be easier just to recreate the drop down with whatever the new options list is.
+        In many cases it may be easier just to recreate the drop-down with whatever the new options list is.
 
         :param new_options: The list of new options to add.
         """
-        self.options_list.extend(new_options)
+        for option in new_options:
+            if isinstance(option, str):
+                self.options_list.append((option, option))
+            else:
+                self.options_list.append(option)
+
         self.menu_states['expanded'].options_list = self.options_list
 
         # if we have the dropdown open, close it so it can be reopened with the new options in place
         self._close_dropdown_if_open()
 
-    def remove_options(self, options_to_remove: List[str]) -> None:
+    def remove_options(self, options_to_remove: Union[List[str], List[Tuple[str, str]]]) -> None:
         """
         Will remove all instances of the options provided.
 
         :param options_to_remove: The list of new options to remove.
         """
-        self.options_list = [option for option in self.options_list if option not in options_to_remove]
+        real_options_to_remove = []
+        for option in options_to_remove:
+            if isinstance(option, str):
+                real_options_to_remove.append((option, option))
+            else:
+                real_options_to_remove.append(option)
+        self.options_list = [option for option in self.options_list if option not in real_options_to_remove]
         self.menu_states['expanded'].options_list = self.options_list
 
         # if we have the dropdown open, close it so it can be reopened with the new options in place
diff --git a/pygame_gui/elements/ui_selection_list.py b/pygame_gui/elements/ui_selection_list.py
index 5a27f56..f138d9e 100644
--- a/pygame_gui/elements/ui_selection_list.py
+++ b/pygame_gui/elements/ui_selection_list.py
@@ -127,15 +127,21 @@ class UISelectionList(UIElement):
         self._raw_item_list = [item for item in self._raw_item_list if item not in items_to_remove]
         self.set_item_list(self._raw_item_list)
 
-    def get_single_selection(self) -> Union[str, None]:
+    def get_single_selection(self, include_object_id: bool = False) -> Union[Tuple[str, str], str, None]:
         """
         Get the selected item in a list, if any. Only works if this is a single-selection list.
 
+        :param include_object_id: if True adds the object id of this list item to the returned list of Tuples.
+                                  If False we just get a list of the visible text strings only.
+
         :return: A single item name as a string or None.
 
         """
         if not self.allow_multi_select:
-            selected_list = [item['text'] for item in self.item_list if item['selected']]
+            if include_object_id:
+                selected_list = [(item['text'], item['object_id']) for item in self.item_list if item['selected']]
+            else:
+                selected_list = [item['text'] for item in self.item_list if item['selected']]
             if len(selected_list) == 1:
                 return selected_list[0]
             elif len(selected_list) == 0:
@@ -147,17 +153,23 @@ class UISelectionList(UIElement):
             raise RuntimeError('Requesting single selection,'
                                ' from multi-selection list')
 
-    def get_multi_selection(self) -> List[str]:
+    def get_multi_selection(self, include_object_id: bool = False) -> Union[List[str], List[Tuple[str, str]]]:
         """
         Get all the selected items in our selection list. Only works if this is a
         multi-selection list.
 
+        :param include_object_id: if True adds the object id of this list item to the returned list of Tuples.
+                                  If False we just get a list of the visible text strings only.
+
         :return: A list of the selected items in our selection list. May be empty if nothing
                  selected.
 
         """
         if self.allow_multi_select:
-            return [item['text'] for item in self.item_list if item['selected']]
+            if include_object_id:
+                return [(item['text'], item["object_id"]) for item in self.item_list if item['selected']]
+            else:
+                return [item['text'] for item in self.item_list if item['selected']]
         else:
             raise RuntimeError('Requesting multi selection, from single-selection list')
 


Test patch:
diff --git a/tests/test_elements/test_ui_drop_down_menu.py b/tests/test_elements/test_ui_drop_down_menu.py
index c21d25b..3c7aa4d 100644
--- a/tests/test_elements/test_ui_drop_down_menu.py
+++ b/tests/test_elements/test_ui_drop_down_menu.py
@@ -55,7 +55,7 @@ class TestUIDropDownMenu:
 
         menu.add_options(['spam', 'bad spam'])
 
-        assert menu.options_list[-1] == 'bad spam'
+        assert menu.options_list[-1] == ('bad spam', 'bad spam')
 
         menu.current_state.should_transition = True
         menu.update(0.01)
@@ -64,7 +64,7 @@ class TestUIDropDownMenu:
 
         menu.add_options(['steak'])
 
-        assert menu.options_list[-1] == 'steak'
+        assert menu.options_list[-1] == ('steak', 'steak')
         assert menu.current_state == menu.menu_states['closed']
 
     def test_removal(self, _init_pygame, default_ui_manager,
@@ -76,7 +76,7 @@ class TestUIDropDownMenu:
 
         menu.remove_options(['flour', 'sugar'])
 
-        assert menu.options_list[-1] == 'eggs'
+        assert menu.options_list[-1] == ('eggs', 'eggs')
 
         menu.current_state.should_transition = True
         menu.update(0.01)
@@ -85,7 +85,7 @@ class TestUIDropDownMenu:
 
         menu.remove_options(['eggs'])
 
-        assert menu.options_list[-1] == 'cheese'
+        assert menu.options_list[-1] == ('cheese', 'cheese')
         assert menu.current_state == menu.menu_states['closed']
 
     def test_kill(self, _init_pygame, default_ui_manager, _display_surface_return_none):
@@ -500,7 +500,7 @@ class TestUIDropDownMenu:
         menu.current_state.should_transition = True
         menu.update(0.01)
 
-        assert menu.selected_option == 'eggs'
+        assert menu.selected_option == ('eggs', 'eggs')
         flour_button = menu.current_state.options_selection_list.item_list_container.elements[1]
 
         flour_button.process_event(pygame.event.Event(pygame.MOUSEBUTTONDOWN,
@@ -517,7 +517,7 @@ class TestUIDropDownMenu:
         for event in pygame.event.get():
             default_ui_manager.process_events(event)
 
-        assert menu.selected_option == 'flour'
+        assert menu.selected_option == ('flour', 'flour')
 
     def test_disable(self, _init_pygame, default_ui_manager, _display_surface_return_none):
         test_container = UIContainer(relative_rect=pygame.Rect(0, 0, 300, 300),
@@ -532,7 +532,7 @@ class TestUIDropDownMenu:
         menu.update(0.01)
 
         assert menu.current_state == menu.menu_states['expanded']
-        assert menu.selected_option == 'eggs'
+        assert menu.selected_option == ('eggs', 'eggs')
 
         menu.disable()
         assert menu.is_enabled is False
@@ -559,8 +559,10 @@ class TestUIDropDownMenu:
     def test_enable(self, _init_pygame, default_ui_manager, _display_surface_return_none):
         test_container = UIContainer(relative_rect=pygame.Rect(0, 0, 300, 300),
                                      manager=default_ui_manager)
-        menu = UIDropDownMenu(options_list=['eggs', 'flour', 'sugar'],
-                              starting_option='eggs',
+        menu = UIDropDownMenu(options_list=[('eggs', 'eggs_id'),
+                                            ('flour', 'flour_id'),
+                                            ('sugar', 'sugar_id')],
+                              starting_option=('eggs', 'eggs_id'),
                               relative_rect=pygame.Rect(10, 10, 200, 30),
                               manager=default_ui_manager,
                               container=test_container)
@@ -569,7 +571,7 @@ class TestUIDropDownMenu:
         menu.update(0.01)
 
         assert menu.current_state == menu.menu_states['expanded']
-        assert menu.selected_option == 'eggs'
+        assert menu.selected_option == ('eggs', 'eggs_id')
 
         menu.disable()
         assert menu.is_enabled is False
@@ -599,7 +601,7 @@ class TestUIDropDownMenu:
         menu.update(0.01)
 
         assert menu.current_state == menu.menu_states['expanded']
-        assert menu.selected_option == 'eggs'
+        assert menu.selected_option == ('eggs', 'eggs_id')
 
     def test_show_of_dropdown(self, _init_pygame, _display_surface_return_none, default_ui_manager):
         menu = UIDropDownMenu(options_list=['eggs', 'flour', 'sugar'],