diff --git a/docs/examples/widgets/option_list_options.py b/docs/examples/widgets/option_list_options.py index 611a7ef08..fe3d2ec29 100644 --- a/docs/examples/widgets/option_list_options.py +++ b/docs/examples/widgets/option_list_options.py @@ -1,6 +1,6 @@ from textual.app import App, ComposeResult from textual.widgets import Footer, Header, OptionList -from textual.widgets.option_list import Option, Separator +from textual.widgets.option_list import Option class OptionListApp(App[None]): @@ -11,22 +11,22 @@ class OptionListApp(App[None]): yield OptionList( Option("Aerilon", id="aer"), Option("Aquaria", id="aqu"), - Separator(), + None, Option("Canceron", id="can"), Option("Caprica", id="cap", disabled=True), - Separator(), + None, Option("Gemenon", id="gem"), - Separator(), + None, Option("Leonis", id="leo"), Option("Libran", id="lib"), - Separator(), + None, Option("Picon", id="pic"), - Separator(), + None, Option("Sagittaron", id="sag"), Option("Scorpia", id="sco"), - Separator(), + None, Option("Tauron", id="tau"), - Separator(), + None, Option("Virgon", id="vir"), ) yield Footer() diff --git a/src/textual/command.py b/src/textual/command.py index 287ee2564..115432581 100644 --- a/src/textual/command.py +++ b/src/textual/command.py @@ -35,7 +35,6 @@ from typing import ( import rich.repr from rich.align import Align -from rich.style import Style from rich.text import Text from typing_extensions import Final, TypeAlias @@ -48,7 +47,7 @@ from textual.fuzzy import Matcher from textual.message import Message from textual.reactive import var from textual.screen import Screen, SystemModalScreen -from textual.style import Style as VisualStyle +from textual.style import Style from textual.timer import Timer from textual.types import IgnoreReturnCallbackType from textual.visual import VisualType @@ -190,6 +189,10 @@ class Provider(ABC): Args: screen: A reference to the active screen. """ + if match_style is not None: + assert isinstance( + match_style, Style + ), "match_style must be a Visual style if given" self.__screen = screen self.__match_style = match_style self._init_task: Task | None = None @@ -228,8 +231,13 @@ class Provider(ABC): Returns: A [fuzzy matcher][textual.fuzzy.Matcher] object for matching against candidate hits. """ + match_style = self.match_style + # match_style = Style(bold=True, underline=True) + return Matcher( - user_input, match_style=self.match_style, case_sensitive=case_sensitive + user_input, + match_style=match_style, + case_sensitive=case_sensitive, ) def _post_init(self) -> None: @@ -806,9 +814,7 @@ class CommandPalette(SystemModalScreen[None]): self.app.post_message(CommandPalette.Opened()) self._calling_screen = self.app.screen_stack[-2] - match_style = self.get_component_rich_style( - "command-palette--highlight", partial=True - ) + match_style = self.get_visual_style("command-palette--highlight", partial=True) assert self._calling_screen is not None self._providers = [ @@ -1108,9 +1114,10 @@ class CommandPalette(SystemModalScreen[None]): yield Content.from_rich_text(hit.prompt) else: yield Content.from_markup(hit.prompt) + # Optional help text if hit.help: - help_style = VisualStyle.from_styles( + help_style = Style.from_styles( self.get_component_styles("command-palette--help-text") ) yield Content.from_markup(hit.help).stylize_before(help_style) diff --git a/src/textual/content.py b/src/textual/content.py index 80667d25c..270da9d6e 100644 --- a/src/textual/content.py +++ b/src/textual/content.py @@ -948,7 +948,7 @@ class Content(Visual): self, base_style: Style = Style.null(), end: str = "\n", - parse_style: Callable[[str], Style] | None = None, + parse_style: Callable[[str | Style], Style] | None = None, ) -> Iterable[tuple[str, Style]]: """Render Content in to an iterable of strings and styles. @@ -971,11 +971,13 @@ class Content(Visual): yield end, base_style return - get_style: Callable[[str], Style] + get_style: Callable[[str | Style], Style] if parse_style is None: - def get_style(style: str, /) -> Style: + def get_style(style: str | Style) -> Style: """The default get_style method.""" + if isinstance(style, Style): + return style try: visual_style = Style.parse(style) except Exception: diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index a715162f3..de1e98e84 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -220,7 +220,7 @@ class Stylesheet: self._parse_cache.clear() self._style_parse_cache.clear() - def parse_style(self, style_text: str) -> Style: + def parse_style(self, style_text: str | Style) -> Style: """Parse a (visual) Style. Args: @@ -229,6 +229,8 @@ class Stylesheet: Returns: New Style instance. """ + if isinstance(style_text, Style): + return style_text if style_text in self._style_parse_cache: return self._style_parse_cache[style_text] style = parse_style(style_text) diff --git a/src/textual/fuzzy.py b/src/textual/fuzzy.py index 337ad29b4..9a7a55014 100644 --- a/src/textual/fuzzy.py +++ b/src/textual/fuzzy.py @@ -12,8 +12,9 @@ from re import IGNORECASE, escape, finditer, search from typing import Iterable, NamedTuple import rich.repr -from rich.style import Style -from rich.text import Text + +from textual.content import Content +from textual.visual import Style class _Search(NamedTuple): @@ -203,7 +204,7 @@ class Matcher: """ return self.fuzzy_search.match(self.query, candidate)[0] - def highlight(self, candidate: str) -> Text: + def highlight(self, candidate: str) -> Content: """Highlight the candidate with the fuzzy match. Args: @@ -212,11 +213,11 @@ class Matcher: Returns: A [rich.text.Text][`Text`] object with highlighted matches. """ - text = Text.from_markup(candidate) + content = Content.from_markup(candidate) score, offsets = self.fuzzy_search.match(self.query, candidate) if not score: - return text + return content for offset in offsets: if not candidate[offset].isspace(): - text.stylize(self._match_style, offset, offset + 1) - return text + content = content.stylize(self._match_style, offset, offset + 1) + return content diff --git a/src/textual/style.py b/src/textual/style.py index 7403a283e..c96c9b7af 100644 --- a/src/textual/style.py +++ b/src/textual/style.py @@ -177,10 +177,14 @@ class Style: new_style = Style( ( other.background - if self.background is None + if (self.background is None or self.background.a == 0) else self.background + other.background ), - self.foreground if other.foreground is None else other.foreground, + ( + self.foreground + if (other.foreground is None or other.foreground.a == 0) + else other.foreground + ), self.bold if other.bold is None else other.bold, self.dim if other.dim is None else other.dim, self.italic if other.italic is None else other.italic, @@ -368,6 +372,7 @@ class Style: bold=self.bold, dim=self.dim, italic=self.italic, + underline=self.underline, reverse=self.reverse, strike=self.strike, link=self.link, diff --git a/src/textual/widget.py b/src/textual/widget.py index 33f8f295e..f3b688808 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1044,17 +1044,20 @@ class Widget(DOMNode): return partial_style if partial else style - def get_visual_style(self, *component_classes: str) -> VisualStyle: + def get_visual_style( + self, *component_classes: str, partial: bool = False + ) -> VisualStyle: """Get the visual style for the widget, including any component styles. Args: component_classes: Optional component styles. + partial: Return a partial style (not combined with parent). Returns: A Visual style instance. """ - cache_key = (self._pseudo_classes_cache_key, component_classes) + cache_key = (self._pseudo_classes_cache_key, component_classes, partial) if (visual_style := self._visual_style_cache.get(cache_key, None)) is None: background = Color(0, 0, 0, 0) color = Color(255, 255, 255, 0) @@ -1064,8 +1067,11 @@ class Widget(DOMNode): def iter_styles() -> Iterable[StylesBase]: """Iterate over the styles from the DOM and additional components styles.""" - for node in reversed(self.ancestors_with_self): - yield node.styles + if partial: + node = self + else: + for node in reversed(self.ancestors_with_self): + yield node.styles for name in component_classes: yield node.get_component_styles(name) @@ -4221,7 +4227,7 @@ class Widget(DOMNode): """ self.app.capture_mouse(None) - def select_all(self) -> None: + def text_select_all(self) -> None: """Select the entire widget.""" self.screen._select_all_in_widget(self) @@ -4286,9 +4292,9 @@ class Widget(DOMNode): async def _on_click(self, event: events.Click) -> None: if event.widget is self: if event.chain == 2: - self.select_all() + self.text_select_all() elif event.chain == 3 and self.parent is not None: - self.select_container.select_all() + self.select_container.text_select_all() await self.broker_event("click", event) diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index dd520a79c..15a5d2271 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -11,7 +11,7 @@ from textual._loop import loop_last from textual.binding import Binding, BindingType from textual.cache import LRUCache from textual.css.styles import RulesMap -from textual.geometry import Region, Size +from textual.geometry import Region, Size, clamp from textual.message import Message from textual.reactive import reactive from textual.scroll_view import ScrollView @@ -57,7 +57,7 @@ class Option: self._prompt = prompt self._visual: Visual | None = None self._id = id - self._disabled = disabled + self.disabled = disabled self._divider = False @property @@ -70,9 +70,15 @@ class Option: """Optional ID for the option.""" return self._id - @property - def disabled(self) -> bool: - return self._disabled + def _set_prompt(self, prompt: VisualType) -> None: + """Update the prompt. + + Args: + prompt: New prompt. + + """ + self._prompt = prompt + self._visual = None def __hash__(self) -> int: return id(self) @@ -180,7 +186,7 @@ class OptionList(ScrollView, can_focus=True): highlighted: reactive[int | None] = reactive(None) """The index of the currently-highlighted option, or `None` if no option is highlighted.""" - hover_option_index: reactive[int | None] = reactive(None) + _mouse_hovering_over: reactive[int | None] = reactive(None) """The index of the option under the mouse or `None`.""" class OptionMessage(Message): @@ -247,14 +253,12 @@ class OptionList(ScrollView, can_focus=True): tooltip: VisualType | None = None, ): super().__init__(name=name, id=id, classes=classes, disabled=disabled) - self._option_id = 0 self._wrap = wrap self._markup = markup self._options: list[Option] = [] self._id_to_option: dict[str, Option] = {} self._option_to_index: dict[Option, int] = {} - self._visuals: dict[int, Visual] = {} self._option_render_cache: LRUCache[tuple[Option, Style], list[Strip]] self._option_render_cache = LRUCache(maxsize=1024) @@ -264,6 +268,9 @@ class OptionList(ScrollView, can_focus=True): self.tooltip = tooltip self.add_options(content) + if self._options: + # TODO: Inherited from previous version. Do we always want this? + self.action_first() @property def options(self) -> Sequence[Option]: @@ -285,8 +292,11 @@ class OptionList(ScrollView, can_focus=True): Returns: The `OptionList` instance. """ + self._options.clear() self._line_cache.clear() self._option_render_cache.clear() + self._id_to_option.clear() + self._option_to_index.clear() self.highlighted = None self.refresh() return self @@ -298,6 +308,16 @@ class OptionList(ScrollView, can_focus=True): new_options: Content of new options. """ + option_ids = [ + option._id + for option in new_options + if isinstance(option, Option) and option._id is not None + ] + if len(option_ids) != len(set(option_ids)): + raise DuplicateID( + "New options contain duplicated IDs; Ensure that the IDs are unique." + ) + options = self._options add_option = self._options.append for prompt in new_options: @@ -309,17 +329,15 @@ class OptionList(ScrollView, can_focus=True): continue else: option = Option(prompt) - hash(option) self._option_to_index[option] = len(options) - add_option(option) if option._id is not None: if option._id in self._id_to_option: - raise DuplicateID("Unable to add {option!r} due to duplicate ID") + raise DuplicateID(f"Unable to add {option!r} due to duplicate ID") self._id_to_option[option._id] = option - + add_option(option) return self - def add_option(self, option: Option | VisualType | None) -> Self: + def add_option(self, option: Option | VisualType | None = None) -> Self: """Add a new option to the end of the option list. Args: @@ -397,7 +415,7 @@ class OptionList(ScrollView, can_focus=True): Returns: The `OptionList` instance. """ - self._options[index]._disabled = disabled + self._options[index].disabled = disabled if index == self.highlighted: self.highlighted = _widget_navigation.find_next_enabled( self._options, anchor=index, direction=1 @@ -480,6 +498,8 @@ class OptionList(ScrollView, can_focus=True): """ index = self._option_to_index[option] + self._mouse_hovering_over = None + self._pre_remove_option(option, index) for option in self.options[index + 1 :]: current_index = self._option_to_index[option] self._option_to_index[option] = current_index - 1 @@ -489,9 +509,19 @@ class OptionList(ScrollView, can_focus=True): if option._id is not None: del self._id_to_option[option._id] del self._option_to_index[option] + self.highlighted = self.highlighted self.refresh() return self + def _pre_remove_option(self, option: Option, index: int) -> None: + """Hook called prior to removing an option. + + Args: + option: Option being removed. + index: Index of option being removed. + + """ + def remove_option(self, option_id: str) -> Self: """Remove the option with the given ID. @@ -519,9 +549,59 @@ class OptionList(ScrollView, can_focus=True): Raises: OptionDoesNotExist: If there is no option with the given index. """ - option = self._options[index] + try: + option = self._options[index] + except IndexError: + raise OptionDoesNotExist( + f"Unable to remove; there is no option at index {index}" + ) from None return self._remove_option(option) + def _replace_option_prompt(self, index: int, prompt: VisualType) -> 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: + OptionDoesNotExist: If there is no option with the given index. + """ + self.get_option_at_index(index)._set_prompt(prompt) + self._clear_caches() + + def replace_option_prompt(self, option_id: str, prompt: VisualType) -> 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. + """ + self._replace_option_prompt(self.get_option_index(option_id), prompt) + return self + + def replace_option_prompt_at_index(self, index: int, prompt: VisualType) -> 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. + """ + self._replace_option_prompt(index, prompt) + return self + @property def _lines(self) -> Sequence[tuple[int, int]]: """A sequence of pairs of ints for each line, used internally. @@ -549,6 +629,7 @@ class OptionList(ScrollView, can_focus=True): def _clear_caches(self) -> None: self._option_render_cache.clear() self._line_cache.clear() + self.refresh() def notify_style_update(self) -> None: self._clear_caches() @@ -557,6 +638,9 @@ class OptionList(ScrollView, can_focus=True): self._clear_caches() self.refresh() + def _on_mount(self) -> None: + self._update_lines() + def on_show(self) -> None: self.scroll_to_highlight() @@ -567,11 +651,7 @@ class OptionList(ScrollView, can_focus=True): event: The click event. """ clicked_option: int | None = event.style.meta.get("option") - if ( - clicked_option is not None - and clicked_option >= 0 - and not self._options[clicked_option].disabled - ): + if clicked_option is not None and not self._options[clicked_option].disabled: self.highlighted = clicked_option self.action_select() @@ -589,7 +669,7 @@ class OptionList(ScrollView, can_focus=True): Args: event: The mouse movement event. """ - self.hover_option_index = event.style.meta.get("option") + self._mouse_hovering_over = event.style.meta.get("option") def _on_leave(self, _: events.Leave) -> None: """React to the mouse leaving the widget.""" @@ -633,7 +713,7 @@ class OptionList(ScrollView, can_focus=True): A list of strips. """ padding = self.get_component_styles("option-list--option").padding - width = self.content_region.width - self._get_left_gutter_width() + width = self.scrollable_content_region.width - self._get_left_gutter_width() cache_key = (option, style) if (strips := self._option_render_cache.get(cache_key)) is None: visual = self._get_visual(option) @@ -657,14 +737,16 @@ class OptionList(ScrollView, can_focus=True): def _update_lines(self) -> None: line_cache = self._line_cache lines = line_cache.lines - last_index = lines[-1][0] if lines else 0 + if not self.options: + return + + next_index = lines[-1][0] + 1 if lines else 0 get_visual = self._get_visual width = self.scrollable_content_region.width - if last_index < len(self.options) - 1: + if next_index < len(self.options): styles = self.get_component_styles("option-list--option") - - for index, option in enumerate(self.options[last_index:], last_index): + for index, option in enumerate(self.options[next_index:], next_index): line_cache.index_to_line[index] = len(line_cache.lines) line_count = ( get_visual(option).get_height(styles, width) + option._divider @@ -716,6 +798,10 @@ class OptionList(ScrollView, can_focus=True): strips = self._get_option_render(option, style) return strips[line_offset] + def render_lines(self, crop: Region) -> list[Strip]: + self._update_lines() + return super().render_lines(crop) + def render_line(self, y: int) -> Strip: line_number = self.scroll_offset.y + y try: @@ -723,25 +809,20 @@ class OptionList(ScrollView, can_focus=True): except IndexError: return Strip.blank(self.scrollable_content_region.width) option = self.options[option_index] - mouse_over = self.hover_option_index == option_index - component_classes: tuple[str, ...] = ("option-list--option",) + mouse_over = self._mouse_hovering_over == option_index + component_class = "" if option.disabled: - component_classes = ( - "option-list--option", - "option-list--option-disabled", - ) + component_class = "option-list--option-disabled" elif self.highlighted == option_index: - component_classes = ( - "option-list--option", - "option-list--option-highlighted", - ) + component_class = "option-list--option-highlighted" elif mouse_over: - component_classes = ( - "option-list--option", - "option-list--option-hover", - ) + component_class = "option-list--option-hover" + + if component_class: + style = self.get_visual_style("option-list--option", component_class) + else: + style = self.get_visual_style("option-list--option") - style = self.get_visual_style(*component_classes) strips = self._get_option_render(option, style) strip = strips[line_offset] return strip @@ -760,23 +841,28 @@ class OptionList(ScrollView, can_focus=True): """React to the highlighted option having changed.""" if highlighted is None: return - if not self._options[highlighted].disabled: self.scroll_to_highlight() - - option: Option - if highlighted is None: - option = None - else: - option = self.options[highlighted] - self.post_message(self.OptionHighlighted(self, option, highlighted)) + self.post_message( + self.OptionHighlighted(self, self.options[highlighted], highlighted) + ) def scroll_to_highlight(self, top: bool = False) -> None: + """Scroll to the highlighted option. + + Args: + top: Ensure highlighted option is at the top of the widget. + """ highlighted = self.highlighted if highlighted is None or not self.is_mounted: return - y = self._index_to_line[highlighted] + self._update_lines() + + try: + y = self._index_to_line[highlighted] + except KeyError: + return option = self.options[highlighted] height = self._heights[highlighted] - option._divider @@ -820,33 +906,36 @@ class OptionList(ScrollView, can_focus=True): Args: direction: `-1` to move up a page, `1` to move down a page. """ + if not self._options: + return - height = self.content_region.height - - option_index = self.highlighted or 0 - - y = min( - self._index_to_line[option_index] + direction * height, + height = self.scrollable_content_region.height + y = clamp( + self._index_to_line[self.highlighted or 0] + direction * height, + 0, len(self._lines) - 1, ) option_index = self._lines[y][0] - - target_option = _widget_navigation.find_next_enabled_no_wrap( + self.highlighted = _widget_navigation.find_next_enabled_no_wrap( candidates=self._options, anchor=option_index, direction=direction, with_anchor=True, ) - if target_option is not None: - self.highlighted = target_option def action_page_up(self): """Move the highlight up one page.""" - self._move_page(-1) + if self.highlighted is None: + self.action_first() + else: + self._move_page(-1) def action_page_down(self): """Move the highlight down one page.""" - self._move_page(1) + if self.highlighted is None: + self.action_last() + else: + self._move_page(1) def action_select(self) -> None: """Select the currently highlighted option. @@ -854,34 +943,9 @@ class OptionList(ScrollView, can_focus=True): If an option is selected then a [OptionList.OptionSelected][textual.widgets.OptionList.OptionSelected] will be posted. """ - if self.highlighted is None: - return highlighted = self.highlighted + if highlighted is None: + return option = self._options[highlighted] if highlighted is not None and not option.disabled: self.post_message(self.OptionSelected(self, option, highlighted)) - - -if __name__ == "__main__": - from textual.app import App, ComposeResult - - TEXT = """I must not fear. -Fear is the [u]mind-killer[/u]. -Fear is the little-death that brings total obliteration. -I will face my fear. -I will permit it to pass over me and through me. -And when it has gone past, I will turn the inner eye to see its path. -Where the fear has gone there will be nothing. Only I will remain.""" - - class OLApp(App): - - def compose(self) -> ComposeResult: - yield OptionList( - *( - ["Hello", "World!", None, TEXT, None, "Foo", "Bar", "Baz", None] - * 100 - ) - ) - - app = OLApp() - app.run() diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 12a3487ef..8f588c8d1 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -15,7 +15,12 @@ from textual import events from textual.binding import Binding from textual.messages import Message from textual.strip import Strip -from textual.widgets._option_list import Option, OptionList, OptionListContent +from textual.widgets._option_list import ( + Option, + OptionDoesNotExist, + OptionList, + OptionListContent, +) from textual.widgets._toggle_button import ToggleButton SelectionType = TypeVar("SelectionType") @@ -229,6 +234,10 @@ class SelectionList(Generic[SelectionType], OptionList): self._send_messages = False """Keep track of when we're ready to start sending messages.""" options = [self._make_selection(selection) for selection in selections] + self._values: dict[SelectionType, int] = { + option.value: index for index, option in enumerate(options) + } + """Keeps track of which value relates to which option.""" super().__init__( *options, name=name, @@ -237,10 +246,6 @@ class SelectionList(Generic[SelectionType], OptionList): disabled=disabled, wrap=False, ) - self._values: dict[SelectionType, int] = { - option.value: index for index, option in enumerate(options) - } - """Keeps track of which value relates to which option.""" @property def selected(self) -> list[SelectionType]: @@ -510,20 +515,25 @@ class SelectionList(Generic[SelectionType], OptionList): A [`Strip`][textual.strip.Strip] that is the line to render. """ - # First off, get the underlying prompt from OptionList. - prompt = super().render_line(y) + # TODO: This is rather crufty and hard to fathom. Candidate for a rewrite. - # If it looks like the prompt itself is actually an empty line... - if not prompt: - # ...get out with that. We don't need to do any more here. - return prompt + # First off, get the underlying prompt from OptionList. + line = super().render_line(y) + + # # If it looks like the prompt itself is actually an empty line... + # if not prompt: + # # ...get out with that. We don't need to do any more here. + # return prompt # We know the prompt we're going to display, what we're going to do # is place a CheckBox-a-like button next to it. So to start with # let's pull out the actual Selection we're looking at right now. _, scroll_y = self.scroll_offset selection_index = scroll_y + y - selection = self.get_option_at_index(selection_index) + try: + selection = self.get_option_at_index(selection_index) + except OptionDoesNotExist: + return line # Figure out which component style is relevant for a checkbox on # this particular line. @@ -533,10 +543,12 @@ class SelectionList(Generic[SelectionType], OptionList): if self.highlighted == selection_index: component_style += "-highlighted" - # Get the underlying style used for the prompt. - underlying_style = next(iter(prompt)).style + # # # Get the underlying style used for the prompt. + underlying_style = next(iter(line)).style or self.rich_style assert underlying_style is not None + # underlying_style = self.rich_style + # Get the style for the button. button_style = self.get_component_rich_style(component_style) @@ -565,7 +577,7 @@ class SelectionList(Generic[SelectionType], OptionList): Segment(ToggleButton.BUTTON_INNER, style=button_style), Segment(ToggleButton.BUTTON_RIGHT, style=side_style), Segment(" ", style=underlying_style), - *prompt, + *line, ] ) @@ -617,24 +629,17 @@ class SelectionList(Generic[SelectionType], OptionList): """ return cast("Selection[SelectionType]", super().get_option(option_id)) - def _remove_option(self, index: int) -> None: - """Remove a selection option from the selection option list. - - Args: - index: The index of the selection option to remove. - - Raises: - IndexError: If there is no selection option of the given index. - """ - option = self.get_option_at_index(index) + def _pre_remove_option(self, option: Option, index: int) -> None: + """Hook called prior to removing an option.""" + assert isinstance(option, Selection) 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( self, diff --git a/tests/option_list/test_option_list_create.py b/tests/option_list/test_option_list_create.py index 69ccce06d..03c0c8772 100644 --- a/tests/option_list/test_option_list_create.py +++ b/tests/option_list/test_option_list_create.py @@ -6,12 +6,7 @@ import pytest from textual.app import App, ComposeResult from textual.widgets import OptionList -from textual.widgets.option_list import ( - DuplicateID, - Option, - OptionDoesNotExist, - Separator, -) +from textual.widgets.option_list import DuplicateID, Option, OptionDoesNotExist class OptionListApp(App[None]): @@ -21,7 +16,7 @@ class OptionListApp(App[None]): yield OptionList( "0", Option("1"), - Separator(), + None, Option("2", disabled=True), None, Option("3", id="3"), diff --git a/tests/option_list/test_option_list_mouse_click.py b/tests/option_list/test_option_list_mouse_click.py index ec0182c47..e41d9c528 100644 --- a/tests/option_list/test_option_list_mouse_click.py +++ b/tests/option_list/test_option_list_mouse_click.py @@ -3,7 +3,7 @@ from __future__ import annotations from textual import on from textual.app import App, ComposeResult from textual.widgets import OptionList -from textual.widgets.option_list import Option, Separator +from textual.widgets.option_list import Option class OptionListApp(App[None]): @@ -12,7 +12,7 @@ class OptionListApp(App[None]): def compose(self) -> ComposeResult: yield OptionList( Option("0"), - Separator(), + None, Option("1"), )