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:
Thank you for your issue. Give us a little time to review it.

PS. You might want to check the [FAQ](https://textual.textualize.io/FAQ/) if you haven't done so already.

This is an automated reply, generated by [FAQtory](https://github.com/willmcgugan/faqtory)

Resolving patch:
diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py
--- a/src/textual/widgets/_selection_list.py
+++ b/src/textual/widgets/_selection_list.py
@@ -646,6 +646,11 @@ def _remove_option(self, index: int) -> None:
         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
--- 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)}