Repository: Textualize/textual

Issue:
SelectionList weird behavior when removing options in its own event handler until freeze


When you interact with a SelectionList and remove the option in one of the event handlers one of the following happens:
- Wrong options is removed
- Option just deselects
- Entire component just freezes (not the entire app;  most likely occurs with the first or last option in the Selection List)


Example code:
```python
from textual.app import App
from textual.widgets import SelectionList
from textual.widgets.selection_list import Selection
from textual.widget import Widget
import typing as t
from string import ascii_letters


class MyApp(App[str]):
    def compose(self) -> t.Iterable[Widget]:
        yield SelectionList(
            *[Selection(letter, letter, True, letter) for letter in ascii_letters],
        )

    def on_selection_list_selection_toggled(
        self, event: SelectionList.SelectionToggled[str]
    ) -> None:
        id_ = event.selection.id
        if id_ is not None:
            event.selection_list.remove_option(id_)


if __name__ == "__main__":
    app = MyApp()
    app.run()
```

It will be helpful if you run the following command and paste the results:


<!-- This is valid Markdown, please paste the following directly in to a GitHub issue -->
# Textual Diagnostics

## Versions

| Name    | Value  |
|---------|--------|
| Textual | 0.58.0 |
| Rich    | 13.7.1 |

## Python

| Name           | Value                                                |
|----------------|------------------------------------------------------|
| Version        | 3.11.2                                               |
| Implementation | CPython                                              |
| Compiler       | GCC 12.2.0                                           |
| Executable     | /home/jan/Documents/dev/trove-setup/.venv/bin/python |

## Operating System

| Name    | Value                                               |
|---------|-----------------------------------------------------|
| System  | Linux                                               |
| Release | 6.1.0-17-amd64                                      |
| Version | #1 SMP PREEMPT_DYNAMIC Debian 6.1.69-1 (2023-12-30) |

## Terminal

| Name                 | Value           |
|----------------------|-----------------|
| Terminal Application | vscode (1.88.1) |
| TERM                 | xterm-256color  |
| COLORTERM            | truecolor       |
| FORCE_COLOR          | *Not set*       |
| NO_COLOR             | *Not set*       |

## Rich Console options

| Name           | Value                |
|----------------|----------------------|
| size           | width=208, height=17 |
| legacy_windows | False                |
| min_width      | 1                    |
| max_width      | 208                  |
| is_terminal    | True                 |
| encoding       | utf-8                |
| max_height     | 17                   |
| justify        | None                 |
| overflow       | None                 |
| no_wrap        | False                |
| highlight      | None                 |
| markup         | None                 |
| height         | None                 |


Video:

https://github.com/Textualize/textual/assets/51451323/eeff467e-6c05-4a02-bac7-b987b65f8275





Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 88847d849..575e3ee22 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,15 +9,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 
 ### Fixed
 
+- Fixed `SelectionList` issues after removing an option https://github.com/Textualize/textual/pull/4464
 - Fixed `ListView` bugs with the initial index https://github.com/Textualize/textual/pull/4452
 
-## Unreleased
-
 ### Changed
 
 - When displaying a message using `App.exit()`, the console no longer highlights things such as numbers.
 
-
 ## [0.58.1] - 2024-05-01
 
 ### Fixed
diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py
index 85f95b132..3f9f961f2 100644
--- a/src/textual/widgets/_selection_list.py
+++ b/src/textual/widgets/_selection_list.py
@@ -646,6 +646,11 @@ class SelectionList(Generic[SelectionType], OptionList):
         option = self.get_option_at_index(index)
         self._deselect(option.value)
         del self._values[option.value]
+        # Decrement index of options after the one we just removed.
+        self._values = {
+            option_value: option_index - 1 if option_index > index else option_index
+            for option_value, option_index in self._values.items()
+        }
         return super()._remove_option(index)
 
     def add_options(


Test patch:
diff --git a/tests/selection_list/test_selection_list_create.py b/tests/selection_list/test_selection_list_create.py
index e189a3c9d..114bb69bd 100644
--- a/tests/selection_list/test_selection_list_create.py
+++ b/tests/selection_list/test_selection_list_create.py
@@ -114,3 +114,12 @@ async def test_options_are_available_soon() -> None:
     selection = Selection("", 0, id="some_id")
     selection_list = SelectionList[int](selection)
     assert selection_list.get_option("some_id") is selection
+
+
+async def test_removing_option_updates_indexes() -> None:
+    async with SelectionListApp().run_test() as pilot:
+        selections = pilot.app.query_one(SelectionList)
+        assert selections._values == {n: n for n in range(5)}
+
+        selections.remove_option_at_index(0)
+        assert selections._values == {n + 1: n for n in range(4)}