Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 29 additions & 14 deletions sqlit/shared/ui/widgets_autocomplete.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)

Expand Down Expand Up @@ -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))
96 changes: 96 additions & 0 deletions tests/ui/test_autocomplete_dropdown.py
Original file line number Diff line number Diff line change
@@ -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