diff --git a/sqlit/shared/ui/widgets_autocomplete.py b/sqlit/shared/ui/widgets_autocomplete.py index 9d63f4b3..09b323f7 100644 --- a/sqlit/shared/ui/widgets_autocomplete.py +++ b/sqlit/shared/ui/widgets_autocomplete.py @@ -10,37 +10,40 @@ class AutocompleteDropdown(VerticalScroll): """Dropdown widget for SQL autocomplete suggestions with scrollbar.""" - - DEFAULT_CSS = """ - AutocompleteDropdown { + MIN_WIDTH = 25 + MAX_WIDTH = 80 + MAX_HEIGHT = 12 + VERTICAL_SCROLLBAR_SIZE = 1 + DEFAULT_CSS = f""" + AutocompleteDropdown {{ layer: autocomplete; width: auto; - min-width: 25; - max-width: 80; + min-width: {MIN_WIDTH}; + max-width: {MAX_WIDTH}; height: auto; - max-height: 12; + max-height: {MAX_HEIGHT}; background: $surface; border: round $border; padding: 0; display: none; - scrollbar-size: 1 1; + scrollbar-size: {VERTICAL_SCROLLBAR_SIZE} 1; constrain: inside inside; - } + }} - AutocompleteDropdown.visible { + AutocompleteDropdown.visible {{ display: block; - } + }} - AutocompleteDropdown .autocomplete-item { + AutocompleteDropdown .autocomplete-item {{ width: 100%; height: 1; padding: 0 1; - } + }} - AutocompleteDropdown .autocomplete-item.selected { + AutocompleteDropdown .autocomplete-item.selected {{ background: $primary; color: $background; - } + }} """ def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -62,6 +65,7 @@ def set_items(self, items: list[str], filter_text: str = "") -> None: self.selected_index = 0 self._rebuild() + self._update_width() # Reset scroll to top self.scroll_to(y=0, animate=False) @@ -124,3 +128,14 @@ def hide(self) -> None: def is_visible(self) -> bool: """Check if dropdown is visible.""" return "visible" in self.classes + + def _update_width(self) -> None: + """Update the width of the dropdown based on filtered items.""" + + width = self.MIN_WIDTH + if self.filtered_items: + padding = 6 # 2 border + 2 padding chars + 2 css padding + scrollbar = self.VERTICAL_SCROLLBAR_SIZE if len(self.filtered_items) > self.MAX_HEIGHT else 0 + width = max(len(item) for item in self.filtered_items) + padding + scrollbar + + self.styles.width = max(self.MIN_WIDTH, min(width, self.MAX_WIDTH)) diff --git a/tests/ui/test_autocomplete_dropdown.py b/tests/ui/test_autocomplete_dropdown.py new file mode 100644 index 00000000..b712442b --- /dev/null +++ b/tests/ui/test_autocomplete_dropdown.py @@ -0,0 +1,96 @@ +"""UI tests for autocomplete dropdown widget.""" + +from __future__ import annotations + +import pytest +from textual.app import App, ComposeResult + +from sqlit.shared.ui.widgets_autocomplete import AutocompleteDropdown + + +class _DropdownTestApp(App): + """Minimal app that mounts an AutocompleteDropdown for testing.""" + + def compose(self) -> ComposeResult: + yield AutocompleteDropdown() + + +class TestAutocompleteDropdownWidth: + """Tests for dynamic width sizing based on filtered items.""" + + @pytest.mark.asyncio + async def test_empty_items_uses_min_width(self) -> None: + """Width should be MIN_WIDTH when there are no filtered items.""" + app = _DropdownTestApp() + async with app.run_test() as pilot: + dropdown = app.query_one(AutocompleteDropdown) + dropdown.set_items([]) + await pilot.pause() + + assert dropdown.styles.width is not None + assert dropdown.styles.width.value == AutocompleteDropdown.MIN_WIDTH + + @pytest.mark.asyncio + async def test_short_items_clamped_to_min_width(self) -> None: + """Items shorter than MIN_WIDTH should result in width clamped to MIN_WIDTH.""" + app = _DropdownTestApp() + async with app.run_test() as pilot: + dropdown = app.query_one(AutocompleteDropdown) + dropdown.set_items(["ab", "cd"]) + await pilot.pause() + + assert dropdown.styles.width is not None + assert dropdown.styles.width.value == AutocompleteDropdown.MIN_WIDTH + + @pytest.mark.asyncio + async def test_width_grows_with_longer_items(self) -> None: + """Longer items should produce a wider dropdown than shorter items.""" + app = _DropdownTestApp() + async with app.run_test() as pilot: + dropdown = app.query_one(AutocompleteDropdown) + + dropdown.set_items(["short"]) + await pilot.pause() + assert dropdown.styles.width is not None + width_short = dropdown.styles.width.value + + dropdown.set_items(["a_much_longer_item_name_here"]) + await pilot.pause() + assert dropdown.styles.width is not None + width_long = dropdown.styles.width.value + + assert width_long > width_short + + @pytest.mark.asyncio + async def test_long_items_clamped_to_max_width(self) -> None: + """Items with length exceeding MAX_WIDTH should be clamped to MAX_WIDTH.""" + app = _DropdownTestApp() + async with app.run_test() as pilot: + dropdown = app.query_one(AutocompleteDropdown) + dropdown.set_items(["a" * 100]) + await pilot.pause() + + assert dropdown.styles.width is not None + assert dropdown.styles.width.value == AutocompleteDropdown.MAX_WIDTH + + @pytest.mark.asyncio + async def test_scrollbar_width_added_when_items_exceed_max_height(self) -> None: + """Width should include scrollbar allowance when item count exceeds MAX_HEIGHT.""" + app = _DropdownTestApp() + async with app.run_test() as pilot: + dropdown = app.query_one(AutocompleteDropdown) + item = "x" * 20 + + # Exactly MAX_HEIGHT items — no scrollbar + dropdown.set_items([item] * AutocompleteDropdown.MAX_HEIGHT) + await pilot.pause() + assert dropdown.styles.width is not None + width_at_max_height = dropdown.styles.width.value + + # More than MAX_HEIGHT — scrollbar kicks in + dropdown.set_items([item] * (AutocompleteDropdown.MAX_HEIGHT + 1)) + await pilot.pause() + assert dropdown.styles.width is not None + width_over_max_height = dropdown.styles.width.value + + assert width_over_max_height == width_at_max_height + AutocompleteDropdown.VERTICAL_SCROLLBAR_SIZE