diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index ffc861dad1..8edd2bbf9d 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -13,6 +13,7 @@ from textual.widgets._button import Button from textual.widgets._checkbox import Checkbox from textual.widgets._collapsible import Collapsible + from textual.widgets._combo_box import ComboBox from textual.widgets._content_switcher import ContentSwitcher from textual.widgets._data_table import DataTable from textual.widgets._digits import Digits @@ -54,6 +55,7 @@ "Button", "Checkbox", "Collapsible", + "ComboBox", "ContentSwitcher", "DataTable", "Digits", diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi index 907ae843b8..c2ee551ad5 100644 --- a/src/textual/widgets/__init__.pyi +++ b/src/textual/widgets/__init__.pyi @@ -2,6 +2,7 @@ from ._button import Button as Button from ._checkbox import Checkbox as Checkbox from ._collapsible import Collapsible as Collapsible +from ._combo_box import ComboBox as ComboBox from ._content_switcher import ContentSwitcher as ContentSwitcher from ._data_table import DataTable as DataTable from ._digits import Digits as Digits diff --git a/src/textual/widgets/_combo_box.py b/src/textual/widgets/_combo_box.py new file mode 100644 index 0000000000..3a8ac48247 --- /dev/null +++ b/src/textual/widgets/_combo_box.py @@ -0,0 +1,355 @@ +from __future__ import annotations + +from typing import Generic, Iterable + +from rich.console import RenderableType +from rich.text import Text + +from textual import events, on +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical +from textual.css.query import NoMatches +from textual.message import Message +from textual.reactive import var +from textual.widgets import Input, OptionList +from textual.widgets._option_list import Option +from textual.widgets._select import SelectType + + +class ComboBoxInput(Input): + """The input for the ComboBox control. + + Posts a bubbling message when it loses focus so the parent ComboBox + can close the overlay. + """ + + class LostFocus(Message): + """Posted when this input loses focus.""" + + def _on_blur(self, event: events.Blur) -> None: + super()._on_blur(event) + self.post_message(self.LostFocus()) + + +class ComboBoxOverlay(OptionList): + """The 'pop-up' overlay for the ComboBox control.""" + + ALLOW_SELECT = False + + +class ComboBox(Generic[SelectType], Vertical, can_focus=False): + """Widget to search and select from a list of possible options. + + A ComboBox consists of an Input to search, and an overlaid OptionList + to select from the filtered options. + """ + + BINDINGS = [ + Binding("down", "cursor_down", "Next option", show=False), + Binding("up", "cursor_up", "Previous option", show=False), + Binding("pagedown", "page_down", "Next page", show=False), + Binding("pageup", "page_up", "Previous page", show=False), + Binding("escape", "dismiss", "Dismiss menu", show=False), + ] + + ALLOW_SELECT = False + + DEFAULT_CSS = """ + ComboBox { + height: auto; + color: $foreground; + + ComboBoxInput { + width: 1fr; + } + + & > ComboBoxOverlay { + width: 1fr; + display: none; + height: auto; + max-height: 12; + overlay: screen; + constrain: none inside; + color: $foreground; + border: tall $border-blurred; + background: $surface; + &:focus { + background-tint: $foreground 5%; + } + & > .option-list--option { + padding: 0 1; + } + } + + &.-expanded { + & > ComboBoxOverlay { + display: block; + } + } + } + """ + + expanded: var[bool] = var(False, init=False) + """True to show the overlay, otherwise False.""" + + value: var[SelectType | None] = var(None, init=False) + """The currently selected internal value. None if no selection is active.""" + + class Selected(Message): + """Posted when a selection has been made.""" + + def __init__(self, combo_box: ComboBox[SelectType], value: SelectType | None) -> None: + super().__init__() + self.combo_box = combo_box + self.value = value + + @property + def control(self) -> ComboBox[SelectType]: + return self.combo_box + + class Cleared(Message): + """Posted when the selection has been cleared.""" + + def __init__(self, combo_box: ComboBox[SelectType]) -> None: + super().__init__() + self.combo_box = combo_box + + @property + def control(self) -> ComboBox[SelectType]: + return self.combo_box + + def __init__( + self, + options: Iterable[tuple[RenderableType, SelectType]] | None = None, + *, + placeholder: str = "Search...", + value: SelectType | None = None, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + disabled: bool = False, + ): + """Initialize the ComboBox control. + + Args: + options: Options to select from. + placeholder: Text to show in the control when no option is selected. + value: Initial value selected. + name: The name of the control. + id: The ID of the control in the DOM. + classes: The CSS classes of the control. + disabled: Whether the control is disabled or not. + """ + super().__init__(name=name, id=id, classes=classes, disabled=disabled) + self.placeholder = placeholder + self._initial_value = value + + # The authoritative list of all options + self._options: list[tuple[RenderableType, SelectType]] = [] + if options is not None: + self._options.extend(options) + + def compose(self) -> ComposeResult: + """Compose the ComboBox with its Input and invisible Overlay.""" + yield ComboBoxInput(placeholder=self.placeholder, id="combo-box-input") + yield ComboBoxOverlay() + + def _on_mount(self, _event: events.Mount) -> None: + """Set initial values and options on mount.""" + if self._initial_value is not None: + # Find the option and set the input + for prompt, val in self._options: + if val == self._initial_value: + input_widget = self.query_one(ComboBoxInput) + with input_widget.prevent(Input.Changed): + input_widget.value = self._get_plain_text(prompt) + self.value = self._initial_value + break + + self._update_overlay() + + def _watch_expanded(self, expanded: bool) -> None: + """Update DOM visibility of overlay when expanded changes.""" + self.set_class(expanded, "-expanded") + # Ensure our input still has focus if we are expanding. + if expanded: + try: + overlay = self.query_one(ComboBoxOverlay) + # Select the first option if nothing is highlighted + if overlay.highlighted is None and overlay.option_count > 0: + overlay.highlighted = 0 + except NoMatches: + pass + + @on(ComboBoxInput.LostFocus) + def _input_blurred(self, event: ComboBoxInput.LostFocus) -> None: + """Close the overlay when the input loses focus.""" + self.expanded = False + + @on(Input.Changed) + def _input_changed(self, event: Input.Changed) -> None: + """When input changes, filter the list and expand.""" + event.stop() + self._update_overlay(event.value) + + try: + overlay = self.query_one(ComboBoxOverlay) + except NoMatches: + return + + if overlay.option_count > 0: + if not self.expanded: + self.expanded = True + else: + self.expanded = False + + @on(ComboBoxOverlay.OptionSelected) + def _option_selected(self, event: ComboBoxOverlay.OptionSelected) -> None: + """Handle when a user clicks on an option.""" + event.stop() + self._select_option(event.option_index) + + @on(Input.Submitted) + def _input_submitted(self, event: Input.Submitted) -> None: + """Handle enter in the input. + + If the overlay is open and has a highlighted option, select it. + Otherwise revert the input to the current selection (or clear it + if nothing was ever selected). This enforces strict predefined- + option selection — free text that doesn't match an option is never + accepted. + """ + event.stop() + try: + overlay = self.query_one(ComboBoxOverlay) + except NoMatches: + return + + if self.expanded and overlay.highlighted is not None: + self._select_option(overlay.highlighted) + else: + self._revert_input() + + def _get_plain_text(self, prompt: RenderableType) -> str: + """Convert a RenderableType to plain text for matching and input setting.""" + if isinstance(prompt, str): + return prompt + elif isinstance(prompt, Text): + return prompt.plain + return str(prompt) + + def _update_overlay(self, search_query: str = "") -> None: + """Update the options in the overlay based on the search query.""" + try: + overlay = self.query_one(ComboBoxOverlay) + except NoMatches: + return + + overlay.clear_options() + + matching_options: list[Option] = [] + search_query = search_query.lower() + + # We keep track of which original Option matches which OptionList index + # by passing the original index into the Option's id field. + for index, (prompt, _) in enumerate(self._options): + prompt_str = self._get_plain_text(prompt) + if search_query in prompt_str.lower(): + matching_options.append(Option(prompt, id=str(index))) + + overlay.add_options(matching_options) + if matching_options and overlay.highlighted is None: + overlay.highlighted = 0 + + def _select_option(self, overlay_index: int) -> None: + """Select an option based on its index in the overlay.""" + try: + overlay = self.query_one(ComboBoxOverlay) + input_widget = self.query_one(ComboBoxInput) + except NoMatches: + return + + # Get the original index from the Option's ID + option_id = overlay.get_option_at_index(overlay_index).id + if option_id is None: + return + + original_index = int(option_id) + prompt, value = self._options[original_index] + + # Update state + self.value = value + + # Pause emitting Changed before we modify the input to prevent recursive updates + with input_widget.prevent(Input.Changed): + input_widget.value = self._get_plain_text(prompt) + + self.expanded = False + + # Move cursor to end of input + input_widget.cursor_position = len(input_widget.value) + + # Emit the selected message + self.post_message(self.Selected(self, value)) + + def _revert_input(self) -> None: + """Revert the input text to match the current selection. + + If there is a selected value, restore its display text. + If there is no selection, clear the input. + """ + try: + input_widget = self.query_one(ComboBoxInput) + except NoMatches: + return + + display_text = "" + if self.value is not None: + for prompt, val in self._options: + if val == self.value: + display_text = self._get_plain_text(prompt) + break + + with input_widget.prevent(Input.Changed): + input_widget.value = display_text + input_widget.cursor_position = len(input_widget.value) + self.expanded = False + + def action_cursor_down(self) -> None: + """Proxy down key to overlay.""" + if not self.expanded: + self.expanded = True + else: + try: + self.query_one(ComboBoxOverlay).action_cursor_down() + except NoMatches: + pass + + def action_cursor_up(self) -> None: + """Proxy up key to overlay.""" + try: + self.query_one(ComboBoxOverlay).action_cursor_up() + except NoMatches: + pass + + def action_page_down(self) -> None: + """Proxy page down to overlay.""" + if not self.expanded: + self.expanded = True + else: + try: + self.query_one(ComboBoxOverlay).action_page_down() + except NoMatches: + pass + + def action_page_up(self) -> None: + """Proxy page up to overlay.""" + try: + self.query_one(ComboBoxOverlay).action_page_up() + except NoMatches: + pass + + def action_dismiss(self) -> None: + """Dismiss the overlay.""" + self.expanded = False diff --git a/src/textual/widgets/combo_box.py b/src/textual/widgets/combo_box.py new file mode 100644 index 0000000000..2be7ffba8e --- /dev/null +++ b/src/textual/widgets/combo_box.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from ._combo_box import ComboBox + +__all__ = ["ComboBox"] diff --git a/tests/test_combo_box.py b/tests/test_combo_box.py new file mode 100644 index 0000000000..0d6d20bef1 --- /dev/null +++ b/tests/test_combo_box.py @@ -0,0 +1,169 @@ +from textual.app import App, ComposeResult +from textual.widgets import ComboBox, Button +from textual.widgets._combo_box import ComboBoxInput, ComboBoxOverlay + + +async def test_combobox_filtering(): + """Test that filtering items works correctly based on input.""" + + class ComboBoxApp(App[None]): + def compose(self) -> ComposeResult: + yield ComboBox([("Apple", 1), ("Banana", 2), ("Cherry", 3), ("Date", 4)]) + + app = ComboBoxApp() + async with app.run_test() as pilot: + combo_box = app.query_one(ComboBox) + overlay = app.query_one(ComboBoxOverlay) + + # Initially there should be 4 options + assert overlay.option_count == 4 + + # Focus and type "a" -> Apple, Banana, Date (3 matches) + await pilot.click(ComboBoxInput) + await pilot.press("a") + assert combo_box.expanded is True + assert overlay.option_count == 3 + + # Type "p" -> "ap" matches Apple only + await pilot.press("p") + assert overlay.option_count == 1 + + # Clear input and type "xyz" -> no options, overlay hides + await pilot.press("home", "shift+end", "delete") + await pilot.press("x", "y", "z") + assert overlay.option_count == 0 + assert combo_box.expanded is False + + +async def test_combobox_selection_flow(): + """Test the flow of selecting an item and emitting the Selected event.""" + + events_received = [] + + class ComboBoxApp(App[None]): + def compose(self) -> ComposeResult: + yield ComboBox([("Apple", 1), ("Banana", 2)], value=1) + + def on_combo_box_selected(self, event: ComboBox.Selected) -> None: + events_received.append(event) + + app = ComboBoxApp() + async with app.run_test() as pilot: + combo_box = app.query_one(ComboBox) + input_widget = app.query_one(ComboBoxInput) + + # Initial value should be Apple (1) + assert combo_box.value == 1 + assert input_widget.value == "Apple" + + # Focus the input, select-all, delete to clear deterministically + await pilot.click(ComboBoxInput) + await pilot.press("home", "shift+end", "delete") + assert input_widget.value == "" + + # Both options should now be visible + overlay = app.query_one(ComboBoxOverlay) + assert combo_box.expanded is True + assert overlay.option_count == 2 + + # Arrow down to Banana (highlighted starts at 0=Apple, down goes to 1=Banana) + await pilot.press("down") + assert overlay.highlighted == 1 + + # Press enter to select Banana + await pilot.press("enter") + + assert combo_box.expanded is False + assert combo_box.value == 2 + assert input_widget.value == "Banana" + assert len(events_received) == 1 + assert events_received[0].value == 2 + + # Now search for Apple again + await pilot.press("home", "shift+end", "delete") + await pilot.press("a", "p") + assert combo_box.expanded is True + await pilot.press("enter") + + assert combo_box.expanded is False + assert combo_box.value == 1 + assert input_widget.value == "Apple" + assert len(events_received) == 2 + assert events_received[1].value == 1 + + +async def test_combobox_blur_closes_overlay(): + """Test that the overlay closes when focus leaves the ComboBox.""" + + class ComboBoxApp(App[None]): + def compose(self) -> ComposeResult: + yield ComboBox([("Apple", 1), ("Banana", 2)]) + yield Button("Other") + + app = ComboBoxApp() + async with app.run_test() as pilot: + combo_box = app.query_one(ComboBox) + + # Focus input and type to open overlay + await pilot.click(ComboBoxInput) + await pilot.press("a") + assert combo_box.expanded is True + + # Click the button to move focus away + await pilot.click(Button) + assert combo_box.expanded is False + + +async def test_combobox_enter_no_match_reverts(): + """Test that pressing Enter with no matching option reverts to current selection.""" + + class ComboBoxApp(App[None]): + def compose(self) -> ComposeResult: + yield ComboBox([("Apple", 1), ("Banana", 2)], value=1) + + app = ComboBoxApp() + async with app.run_test() as pilot: + combo_box = app.query_one(ComboBox) + input_widget = app.query_one(ComboBoxInput) + + assert combo_box.value == 1 + assert input_widget.value == "Apple" + + # Type something that matches nothing + await pilot.click(ComboBoxInput) + await pilot.press("home", "shift+end", "delete") + await pilot.press("x", "y", "z") + assert combo_box.expanded is False + assert input_widget.value == "xyz" + + # Press Enter — should revert to "Apple" since no match + await pilot.press("enter") + assert combo_box.value == 1 + assert input_widget.value == "Apple" + assert combo_box.expanded is False + + +async def test_combobox_enter_no_match_clears_when_no_selection(): + """Test that Enter with no match and no prior selection clears the input.""" + + class ComboBoxApp(App[None]): + def compose(self) -> ComposeResult: + yield ComboBox([("Apple", 1), ("Banana", 2)]) + + app = ComboBoxApp() + async with app.run_test() as pilot: + combo_box = app.query_one(ComboBox) + input_widget = app.query_one(ComboBoxInput) + + assert combo_box.value is None + + # Type something that matches nothing + await pilot.click(ComboBoxInput) + await pilot.press("x", "y", "z") + assert input_widget.value == "xyz" + + # Press Enter — should clear input since there's no prior selection + await pilot.press("enter") + assert combo_box.value is None + assert input_widget.value == "" + assert combo_box.expanded is False