diff --git a/sqlit/domains/query/editing/clipboard.py b/sqlit/domains/query/editing/clipboard.py index adb4c8e4..8040877b 100644 --- a/sqlit/domains/query/editing/clipboard.py +++ b/sqlit/domains/query/editing/clipboard.py @@ -27,6 +27,7 @@ def select_all_range(text: str) -> tuple[int, int, int, int]: def paste_text(text: str, row: int, col: int, clipboard: str) -> PasteResult: """Paste clipboard content at cursor position.""" + clipboard = clipboard.replace("\r\n", "\n").replace("\r", "\n") lines = text.split("\n") if not lines: lines = [""] diff --git a/sqlit/domains/query/ui/mixins/autocomplete.py b/sqlit/domains/query/ui/mixins/autocomplete.py index b2d9ab35..cda9d6ab 100644 --- a/sqlit/domains/query/ui/mixins/autocomplete.py +++ b/sqlit/domains/query/ui/mixins/autocomplete.py @@ -88,20 +88,28 @@ def _apply_autocomplete(self: AutocompleteMixinHost) -> None: def _location_to_offset(self, text: str, location: tuple[int, int]) -> int: """Convert (row, col) location to text offset.""" row, col = location - lines = text.split("\n") - offset = sum(len(lines[i]) + 1 for i in range(row)) - offset += col + lines = text.splitlines(keepends=True) + offset = sum(len(line) for line in lines[:row]) + col return min(offset, len(text)) def _offset_to_location(self, text: str, offset: int) -> tuple[int, int]: """Convert text offset to (row, col) location.""" - lines = text.split("\n") + lines = text.splitlines(keepends=True) + if not lines: + return (0, 0) + current_offset = 0 for row, line in enumerate(lines): - if current_offset + len(line) >= offset: + content_length = len(line.rstrip("\r\n")) + if current_offset + content_length >= offset: return (row, offset - current_offset) - current_offset += len(line) + 1 - return (len(lines) - 1, len(lines[-1]) if lines else 0) + current_offset += len(line) + if offset < current_offset: + return (row, content_length) + + if lines and lines[-1].endswith(("\r", "\n")): + return (len(lines), 0) + return (len(lines) - 1, len(lines[-1])) def on_text_area_changed(self: AutocompleteMixinHost, event: TextArea.Changed) -> None: """Handle text changes in the query editor for autocomplete.""" diff --git a/tests/unit/test_autocomplete_cursor_positions.py b/tests/unit/test_autocomplete_cursor_positions.py new file mode 100644 index 00000000..5de1e4bc --- /dev/null +++ b/tests/unit/test_autocomplete_cursor_positions.py @@ -0,0 +1,32 @@ +"""Tests for cursor position conversion used by autocomplete.""" + +import pytest + +from sqlit.domains.query.ui.mixins.autocomplete import AutocompleteMixin + + +@pytest.mark.parametrize("separator", ["\n", "\r\n", "\r"]) +def test_cursor_location_round_trip_for_line_endings(separator: str) -> None: + """Cursor conversion supports all common pasted-text line endings.""" + lines = ["SELECT", " column_name", "FROM users"] + text = separator.join(lines) + location = (2, 4) + expected_offset = len(lines[0]) + len(separator) + len(lines[1]) + len(separator) + location[1] + + offset = AutocompleteMixin._location_to_offset(None, text, location) + + assert offset == expected_offset + assert AutocompleteMixin._offset_to_location(None, text, offset) == location + + +@pytest.mark.parametrize("separator", ["\n", "\r\n", "\r"]) +def test_offset_to_location_after_trailing_line_ending(separator: str) -> None: + """An offset after a trailing line ending maps to the next empty row.""" + text = f"SELECT{separator}" + + assert AutocompleteMixin._offset_to_location(None, text, len(text)) == (1, 0) + + +def test_offset_to_location_for_empty_text() -> None: + """An empty editor starts at the first row and column.""" + assert AutocompleteMixin._offset_to_location(None, "", 0) == (0, 0) diff --git a/tests/unit/test_query_paste.py b/tests/unit/test_query_paste.py new file mode 100644 index 00000000..3001f562 --- /dev/null +++ b/tests/unit/test_query_paste.py @@ -0,0 +1,15 @@ +"""Tests for pasting text into the query editor.""" + +import pytest + +from sqlit.domains.query.editing import PasteResult, paste_text + + +@pytest.mark.parametrize("separator", ["\n", "\r\n", "\r"]) +def test_paste_normalizes_line_endings(separator: str) -> None: + """Pasted queries use the line endings expected by editor operations.""" + clipboard = separator.join(["SELECT", "FROM users"]) + + result = paste_text("", 0, 0, clipboard) + + assert result == PasteResult("SELECT\nFROM users", 1, 10)