From 45b6973d855822e2861110666befb6050d36591b Mon Sep 17 00:00:00 2001 From: Szymon Cader Date: Sat, 22 Jul 2023 12:41:46 +0200 Subject: [PATCH 1/8] Add ability to replace prompt of an individual option in OptionList --- src/textual/widgets/_option_list.py | 67 +++++++++++++++++++ .../test_option_prompt_replacement.py | 41 ++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 tests/option_list/test_option_prompt_replacement.py diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index 2c2bf5e480..cfdc51bfcf 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -55,6 +55,14 @@ def prompt(self) -> RenderableType: """The prompt for the option.""" return self.__prompt + def set_prompt(self, prompt: RenderableType) -> None: + """Set the prompt for the option. + + Args: + prompt: The new prompt for the option. + """ + self.__prompt = prompt + @property def id(self) -> str | None: """The optional ID for the option.""" @@ -647,6 +655,65 @@ def remove_option_at_index(self, index: int) -> Self: ) from None return self + def _replace_option_prompt(self, index: int, prompt: RenderableType) -> None: + """Replace the prompt of an option in the list. + + Args: + index: The index of the option to replace the prompt of. + prompt: The new prompt for the option. + + Raises: + IndexError: If there is no option of the given index. + """ + option = self._options[index] + option.set_prompt(prompt) + self._refresh_content_tracking(force=True) + self.refresh() + + def replace_option_prompt(self, option_id: str, prompt: RenderableType) -> Self: + """Replace the prompt of the option with the given ID. + + Args: + option_id: The ID of the option to replace the prompt of. + prompt: The new prompt for the option. + + Returns: + The `OptionList` instance. + + Raises: + OptionDoesNotExist: If no option has the given ID. + """ + try: + self._replace_option_prompt(self._option_ids[option_id], prompt) + except KeyError: + raise OptionDoesNotExist( + f"There is no option with an ID of '{option_id}'" + ) from None + return self + + def replace_option_prompt_at_index( + self, index: int, prompt: RenderableType + ) -> Self: + """Replace the prompt of the option at the given index. + + Args: + index: The index of the option to replace the prompt of. + prompt: The new prompt for the option. + + Returns: + The `OptionList` instance. + + Raises: + OptionDoesNotExist: If there is no option with the given index. + """ + try: + self._replace_option_prompt(index, prompt) + except IndexError: + raise OptionDoesNotExist( + f"There is no option with an index of {index}" + ) from None + return self + def clear_options(self) -> Self: """Clear the content of the option list. diff --git a/tests/option_list/test_option_prompt_replacement.py b/tests/option_list/test_option_prompt_replacement.py new file mode 100644 index 0000000000..900c97d0a2 --- /dev/null +++ b/tests/option_list/test_option_prompt_replacement.py @@ -0,0 +1,41 @@ +"""Test replacing options prompt from an option list.""" +import pytest + +from textual.app import App, ComposeResult +from textual.widgets import OptionList +from textual.widgets.option_list import Option, OptionDoesNotExist + + +class OptionListApp(App[None]): + """Test option list application.""" + + def compose(self) -> ComposeResult: + yield OptionList( + Option("0", id="0"), + Option("1"), + ) + +async def test_replace_option_prompt_with_invalid_id() -> None: + """Attempting to replace the prompt of an option ID that doesn't exist should raise an exception.""" + async with OptionListApp().run_test() as pilot: + with pytest.raises(OptionDoesNotExist): + pilot.app.query_one(OptionList).replace_option_prompt("does-not-exist", "new-prompt") + +async def test_replace_option_prompt_with_invalid_index() -> None: + """Attempting to replace the prompt of an option index that doesn't exist should raise an exception.""" + async with OptionListApp().run_test() as pilot: + with pytest.raises(OptionDoesNotExist): + pilot.app.query_one(OptionList).replace_option_prompt_at_index(23, "new-prompt") + +async def test_replace_option_prompt_with_valid_id() -> None: + """It should be possible to replace the prompt of an option ID that does exist.""" + async with OptionListApp().run_test() as pilot: + option_list = pilot.app.query_one(OptionList) + option_list.replace_option_prompt("0", "new-prompt") + assert option_list.get_option("0").prompt == "new-prompt" + +async def test_replace_option_prompt_with_valid_index() -> None: + """It should be possible to replace the prompt of an option index that does exist.""" + async with OptionListApp().run_test() as pilot: + option_list = pilot.app.query_one(OptionList).replace_option_prompt_at_index(1, "new-prompt") + assert option_list.get_option_at_index(1).prompt == "new-prompt" From 37bc2e587fd80f26aaa32aab7edee159adf62be6 Mon Sep 17 00:00:00 2001 From: Szymon Cader Date: Sat, 22 Jul 2023 12:54:31 +0200 Subject: [PATCH 2/8] Update the CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a1770c00b..8e9bfe956a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed relative units not always expanding auto containers https://github.com/Textualize/textual/pull/3059 - Fixed background refresh https://github.com/Textualize/textual/issues/3055 +### Added +- Added an interface for replacing prompt of an individual option in an `OptionList` https://github.com/Textualize/textual/issues/2603 ## [0.32.0] - 2023-08-03 From 22926cbd6eb263ab57fe81ec418cdb81c243c2b3 Mon Sep 17 00:00:00 2001 From: Szymon Cader Date: Sat, 22 Jul 2023 14:52:37 +0200 Subject: [PATCH 3/8] Make use of get_option_at_index instead of directly accessing _options --- src/textual/widgets/_option_list.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index cfdc51bfcf..33f4175359 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -665,8 +665,7 @@ def _replace_option_prompt(self, index: int, prompt: RenderableType) -> None: Raises: IndexError: If there is no option of the given index. """ - option = self._options[index] - option.set_prompt(prompt) + self.get_option_at_index(index).set_prompt(prompt) self._refresh_content_tracking(force=True) self.refresh() From a3590ac19253b43bb2f92d1264ea1fd4ab981426 Mon Sep 17 00:00:00 2001 From: Szymon Cader Date: Sat, 22 Jul 2023 15:24:59 +0200 Subject: [PATCH 4/8] Move error handling for non-existing options to dedicated methods --- src/textual/widgets/_option_list.py | 33 ++++++++++++++++------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index 33f4175359..40efd5cefd 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -678,16 +678,8 @@ def replace_option_prompt(self, option_id: str, prompt: RenderableType) -> Self: Returns: The `OptionList` instance. - - Raises: - OptionDoesNotExist: If no option has the given ID. """ - try: - self._replace_option_prompt(self._option_ids[option_id], prompt) - except KeyError: - raise OptionDoesNotExist( - f"There is no option with an ID of '{option_id}'" - ) from None + self._replace_option_prompt(self.get_option_index(option_id), prompt) return self def replace_option_prompt_at_index( @@ -705,12 +697,7 @@ def replace_option_prompt_at_index( Raises: OptionDoesNotExist: If there is no option with the given index. """ - try: - self._replace_option_prompt(index, prompt) - except IndexError: - raise OptionDoesNotExist( - f"There is no option with an index of {index}" - ) from None + self._replace_option_prompt(index, prompt) return self def clear_options(self) -> Self: @@ -855,6 +842,22 @@ def get_option(self, option_id: str) -> Option: f"There is no option with an ID of '{option_id}'" ) from None + def get_option_index(self, option_id): + """Get the index of the option with the given ID. + + Args: + option_id: The ID of the option to get the index of. + + Raises: + OptionDoesNotExist: If no option has the given ID. + """ + try: + return self._option_ids[option_id] + except: + raise OptionDoesNotExist( + f"There is no option with an ID of '{option_id}'" + ) from None + def render_line(self, y: int) -> Strip: """Render a single line in the option list. From d63ac2c22e45adecb17650e0697d68bdce3f368f Mon Sep 17 00:00:00 2001 From: Szymon Cader Date: Sat, 22 Jul 2023 23:46:18 +0200 Subject: [PATCH 5/8] Minor fixes --- src/textual/widgets/_option_list.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index 40efd5cefd..73f60e033d 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -663,7 +663,7 @@ def _replace_option_prompt(self, index: int, prompt: RenderableType) -> None: prompt: The new prompt for the option. Raises: - IndexError: If there is no option of the given index. + OptionDoesNotExist: If there is no option with the given index. """ self.get_option_at_index(index).set_prompt(prompt) self._refresh_content_tracking(force=True) @@ -678,6 +678,9 @@ def replace_option_prompt(self, option_id: str, prompt: RenderableType) -> Self: Returns: The `OptionList` instance. + + Raises: + OptionDoesNotExist: If no option has the given ID. """ self._replace_option_prompt(self.get_option_index(option_id), prompt) return self @@ -814,7 +817,7 @@ def get_option_at_index(self, index: int) -> Option: The option at that index. Raises: - OptionDoesNotExist: If there is no option with the index. + OptionDoesNotExist: If there is no option with the given index. """ try: return self._options[index] @@ -853,7 +856,7 @@ def get_option_index(self, option_id): """ try: return self._option_ids[option_id] - except: + except KeyError: raise OptionDoesNotExist( f"There is no option with an ID of '{option_id}'" ) from None From 913dce7ef7a203e8b8a706b8724f8c564148557f Mon Sep 17 00:00:00 2001 From: Szymon Cader Date: Sun, 23 Jul 2023 21:54:29 +0200 Subject: [PATCH 6/8] Make use of get_option_index This simplifies error handling in a public interface --- src/textual/widgets/_option_list.py | 28 ++++------------------------ 1 file changed, 4 insertions(+), 24 deletions(-) diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index 73f60e033d..9d32806449 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -627,12 +627,7 @@ def remove_option(self, option_id: str) -> Self: Raises: OptionDoesNotExist: If no option has the given ID. """ - try: - self._remove_option(self._option_ids[option_id]) - except KeyError: - raise OptionDoesNotExist( - f"There is no option with an ID of '{option_id}'" - ) from None + self._remove_option(self.get_option_index(option_id)) return self def remove_option_at_index(self, index: int) -> Self: @@ -776,12 +771,7 @@ def enable_option(self, option_id: str) -> Self: Raises: OptionDoesNotExist: If no option has the given ID. """ - try: - return self.enable_option_at_index(self._option_ids[option_id]) - except KeyError: - raise OptionDoesNotExist( - f"There is no option with an ID of '{option_id}'" - ) from None + return self.enable_option_at_index(self.get_option_index(option_id)) def disable_option(self, option_id: str) -> Self: """Disable the option with the given ID. @@ -795,12 +785,7 @@ def disable_option(self, option_id: str) -> Self: Raises: OptionDoesNotExist: If no option has the given ID. """ - try: - return self.disable_option_at_index(self._option_ids[option_id]) - except KeyError: - raise OptionDoesNotExist( - f"There is no option with an ID of '{option_id}'" - ) from None + return self.disable_option_at_index(self.get_option_index(option_id)) @property def option_count(self) -> int: @@ -838,12 +823,7 @@ def get_option(self, option_id: str) -> Option: Raises: OptionDoesNotExist: If no option has the given ID. """ - try: - return self.get_option_at_index(self._option_ids[option_id]) - except KeyError: - raise OptionDoesNotExist( - f"There is no option with an ID of '{option_id}'" - ) from None + return self.get_option_at_index(self.get_option_index(option_id)) def get_option_index(self, option_id): """Get the index of the option with the given ID. From 3ff5b7b82abdd04a5d117d752fddcf2054261df8 Mon Sep 17 00:00:00 2001 From: Szymon Cader Date: Fri, 28 Jul 2023 23:30:34 +0200 Subject: [PATCH 7/8] Add unit tests for multiline prompts --- .../test_option_prompt_replacement.py | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/tests/option_list/test_option_prompt_replacement.py b/tests/option_list/test_option_prompt_replacement.py index 900c97d0a2..ef11b75383 100644 --- a/tests/option_list/test_option_prompt_replacement.py +++ b/tests/option_list/test_option_prompt_replacement.py @@ -12,21 +12,24 @@ class OptionListApp(App[None]): def compose(self) -> ComposeResult: yield OptionList( Option("0", id="0"), - Option("1"), + Option("line1\nline2"), ) + async def test_replace_option_prompt_with_invalid_id() -> None: """Attempting to replace the prompt of an option ID that doesn't exist should raise an exception.""" async with OptionListApp().run_test() as pilot: with pytest.raises(OptionDoesNotExist): pilot.app.query_one(OptionList).replace_option_prompt("does-not-exist", "new-prompt") + async def test_replace_option_prompt_with_invalid_index() -> None: """Attempting to replace the prompt of an option index that doesn't exist should raise an exception.""" async with OptionListApp().run_test() as pilot: with pytest.raises(OptionDoesNotExist): pilot.app.query_one(OptionList).replace_option_prompt_at_index(23, "new-prompt") + async def test_replace_option_prompt_with_valid_id() -> None: """It should be possible to replace the prompt of an option ID that does exist.""" async with OptionListApp().run_test() as pilot: @@ -34,8 +37,36 @@ async def test_replace_option_prompt_with_valid_id() -> None: option_list.replace_option_prompt("0", "new-prompt") assert option_list.get_option("0").prompt == "new-prompt" + async def test_replace_option_prompt_with_valid_index() -> None: """It should be possible to replace the prompt of an option index that does exist.""" async with OptionListApp().run_test() as pilot: option_list = pilot.app.query_one(OptionList).replace_option_prompt_at_index(1, "new-prompt") assert option_list.get_option_at_index(1).prompt == "new-prompt" + + +async def test_replace_single_line_option_prompt_with_multiple() -> None: + """It should be possible to replace single line prompt with multiple lines """ + new_prompt = "new-prompt\nsecond line" + async with OptionListApp().run_test() as pilot: + option_list = pilot.app.query_one(OptionList) + option_list.replace_option_prompt("0", new_prompt) + assert option_list.get_option("0").prompt == new_prompt + + +async def test_replace_multiple_line_option_prompt_with_single() -> None: + """It should be possible to replace multiple line prompt with a single line""" + new_prompt = "new-prompt" + async with OptionListApp().run_test() as pilot: + option_list = pilot.app.query_one(OptionList) + option_list.replace_option_prompt("0", new_prompt) + assert option_list.get_option("0").prompt == new_prompt + + +async def test_replace_multiple_line_option_prompt_with_multiple() -> None: + """It should be possible to replace multiple line prompt with multiple lines""" + new_prompt = "new-prompt\nsecond line" + async with OptionListApp().run_test() as pilot: + option_list = pilot.app.query_one(OptionList) + option_list.replace_option_prompt_at_index(1, new_prompt) + assert option_list.get_option_at_index(1).prompt == new_prompt From def26288e77300650dc5e3a04259f15127cfb329 Mon Sep 17 00:00:00 2001 From: Szymon Cader Date: Sat, 29 Jul 2023 10:27:44 +0200 Subject: [PATCH 8/8] Add snapshot tests for replacing prompt in option list --- .../__snapshots__/test_snapshots.ambr | 483 ++++++++++++++++++ .../option_list_multiline_options.py | 32 ++ tests/snapshot_tests/test_snapshots.py | 12 + 3 files changed, 527 insertions(+) create mode 100644 tests/snapshot_tests/snapshot_apps/option_list_multiline_options.py diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 34042cbe23..803455113a 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -20277,6 +20277,489 @@ ''' # --- +# name: test_option_list_replace_prompt_from_single_line_to_single_line + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + OptionListApp + + + + + + + + + + OptionListApp + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + 1. Another single line + 2. Two + lines + 3. Three + lines + of text + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + + + + ''' +# --- +# name: test_option_list_replace_prompt_from_single_line_to_two_lines + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + OptionListApp + + + + + + + + + + OptionListApp + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + 1. Two + lines + 2. Two + lines + 3. Three + lines + of text + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + + + ''' +# --- +# name: test_option_list_replace_prompt_from_two_lines_to_three_lines + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + OptionListApp + + + + + + + + + + OptionListApp + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + 1. Single line + 1. Three + lines + of text + 3. Three + lines + of text + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + + + ''' +# --- # name: test_option_list_strings ''' diff --git a/tests/snapshot_tests/snapshot_apps/option_list_multiline_options.py b/tests/snapshot_tests/snapshot_apps/option_list_multiline_options.py new file mode 100644 index 0000000000..fe7e4bcf6f --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/option_list_multiline_options.py @@ -0,0 +1,32 @@ +from __future__ import annotations + + +from textual.app import App, ComposeResult +from textual.widgets import OptionList, Header, Footer +from textual.widgets.option_list import Option + + +class OptionListApp(App[None]): + + def compose(self) -> ComposeResult: + yield Header() + yield OptionList( + Option("1. Single line", id="one"), + Option("2. Two\nlines", id="two"), + Option("3. Three\nlines\nof text", id="three"), + ) + + yield Footer() + + def key_1(self): + self.query_one(OptionList).replace_option_prompt_at_index(0, "1. Another single line") + + def key_2(self): + self.query_one(OptionList).replace_option_prompt_at_index(0, "1. Two\nlines") + + def key_3(self): + self.query_one(OptionList).replace_option_prompt_at_index(1, "1. Three\nlines\nof text") + + +if __name__ == "__main__": + OptionListApp().run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 817eb64cf1..5c2d07b24e 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -238,6 +238,18 @@ def test_option_list_build(snap_compare): assert snap_compare(SNAPSHOT_APPS_DIR / "option_list.py") +def test_option_list_replace_prompt_from_single_line_to_single_line(snap_compare): + assert snap_compare(SNAPSHOT_APPS_DIR / "option_list_multiline_options.py", press=["1"]) + + +def test_option_list_replace_prompt_from_single_line_to_two_lines(snap_compare): + assert snap_compare(SNAPSHOT_APPS_DIR / "option_list_multiline_options.py", press=["2"]) + + +def test_option_list_replace_prompt_from_two_lines_to_three_lines(snap_compare): + assert snap_compare(SNAPSHOT_APPS_DIR / "option_list_multiline_options.py", press=["3"]) + + def test_progress_bar_indeterminate(snap_compare): assert snap_compare(WIDGET_EXAMPLES_DIR / "progress_bar_isolated_.py", press=["f"])