diff --git a/sqlit/domains/query/ui/mixins/autocomplete.py b/sqlit/domains/query/ui/mixins/autocomplete.py index 9e6eead7..ab346f92 100644 --- a/sqlit/domains/query/ui/mixins/autocomplete.py +++ b/sqlit/domains/query/ui/mixins/autocomplete.py @@ -86,15 +86,28 @@ def _apply_autocomplete(self: AutocompleteMixinHost) -> None: self._hide_autocomplete() def _location_to_offset(self, text: str, location: tuple[int, int]) -> int: - """Convert (row, col) location to text offset.""" + """Convert (row, col) location to text offset. + + TextArea cursor locations can briefly become stale while debounced + autocomplete callbacks are waiting to run. Clamp the location to the + current text so stale row values do not index past the available lines. + """ row, col = location lines = text.split("\n") - offset = sum(len(lines[i]) + 1 for i in range(row)) - offset += col + + if row <= 0: + return max(0, min(col, len(lines[0]) if lines else 0)) + + if row >= len(lines): + return len(text) + + offset = sum(len(line) + 1 for line in lines[:row]) + offset += max(0, min(col, len(lines[row]))) return min(offset, len(text)) def _offset_to_location(self, text: str, offset: int) -> tuple[int, int]: """Convert text offset to (row, col) location.""" + offset = max(0, min(offset, len(text))) lines = text.split("\n") current_offset = 0 for row, line in enumerate(lines): diff --git a/tests/unit/test_autocomplete_location.py b/tests/unit/test_autocomplete_location.py new file mode 100644 index 00000000..ef9e955f --- /dev/null +++ b/tests/unit/test_autocomplete_location.py @@ -0,0 +1,25 @@ +"""Tests for autocomplete cursor location conversion.""" + +from sqlit.domains.query.ui.mixins.autocomplete import AutocompleteMixin + + +def test_location_to_offset_clamps_stale_row_to_end() -> None: + """A stale debounced cursor row should not crash on single-line text.""" + mixin = AutocompleteMixin() + + assert mixin._location_to_offset("SELECT * FROM users", (4, 0)) == len("SELECT * FROM users") + + +def test_location_to_offset_clamps_column_to_current_line() -> None: + """Columns past the line end should clamp to the line length.""" + mixin = AutocompleteMixin() + + assert mixin._location_to_offset("SELECT\nFROM", (1, 99)) == len("SELECT\nFROM") + + +def test_offset_to_location_clamps_offset_to_text_bounds() -> None: + """Offset conversion should keep cursor positions inside the document.""" + mixin = AutocompleteMixin() + + assert mixin._offset_to_location("SELECT", -10) == (0, 0) + assert mixin._offset_to_location("SELECT", 999) == (0, len("SELECT"))