Skip to content
Draft
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
1 change: 1 addition & 0 deletions sqlit/domains/query/editing/clipboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [""]
Expand Down
22 changes: 15 additions & 7 deletions sqlit/domains/query/ui/mixins/autocomplete.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
32 changes: 32 additions & 0 deletions tests/unit/test_autocomplete_cursor_positions.py
Original file line number Diff line number Diff line change
@@ -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)
15 changes: 15 additions & 0 deletions tests/unit/test_query_paste.py
Original file line number Diff line number Diff line change
@@ -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)