Skip to content
Merged
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
33 changes: 32 additions & 1 deletion sqlit/shared/ui/widgets_autocomplete.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,31 @@
from typing import Any

from textual.containers import VerticalScroll
from textual.message import Message
from textual.widgets import Static


class AutocompleteItem(Static):
"""Clickable autocomplete row."""

class Selected(Message):
"""Posted when a suggestion is clicked."""

def __init__(self, index: int) -> None:
super().__init__()
self.index = index

def __init__(self, text: str, index: int) -> None:
super().__init__(f" {text} ", classes="autocomplete-item")
self.index = index

def on_click(self) -> None:
self.post_message(self.Selected(self.index))


class AutocompleteDropdown(VerticalScroll):
"""Dropdown widget for SQL autocomplete suggestions with scrollbar."""

MIN_WIDTH = 25
MAX_WIDTH = 80
MAX_HEIGHT = 12
Expand Down Expand Up @@ -99,6 +119,17 @@ def get_selected(self) -> str | None:
return self.filtered_items[self.selected_index]
return None

def on_autocomplete_item_selected(self, event: AutocompleteItem.Selected) -> None:
"""Select the clicked row and ask the app to apply it."""
if not (0 <= event.index < len(self.filtered_items)):
return
old_index = self.selected_index
self.selected_index = event.index
self._update_selection(old_index, self.selected_index)
action = getattr(self.app, "action_autocomplete_accept", None)
if callable(action):
action()

def _rebuild(self) -> None:
"""Rebuild the dropdown content (only called when items change)."""
# Remove all existing children
Expand All @@ -110,7 +141,7 @@ def _rebuild(self) -> None:

# Create item widgets
for i, item in enumerate(self.filtered_items):
label = Static(f" {item} ", classes="autocomplete-item")
label = AutocompleteItem(item, i)
if i == self.selected_index:
label.add_class("selected")
self.mount(label)
Expand Down
69 changes: 61 additions & 8 deletions sqlit/shared/ui/widgets_text_area.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,64 @@ def _watch_has_focus(self, focus: bool) -> None:
super()._watch_has_focus(focus)
self._sync_terminal_cursor()

async def _dispatch_query_insert_action(self, key: str) -> bool:
"""Run a query-insert binding before TextArea consumes the key."""
if not self._is_insert_mode():
return False

from sqlit.core.keymap import get_keymap

clipboard_actions = {"select_all", "copy_selection", "paste"}
for binding in get_keymap().get_action_keys():
if (
binding.key == key
and binding.context == "query_insert"
and binding.action not in clipboard_actions
and self.app.check_action(binding.action, ()) is not False
):
return await self.app.run_action(binding.action)
return False

async def _handle_autocomplete_enter(self) -> bool:
"""Honor an explicit Enter autocomplete binding, or insert a newline."""
app = cast("AutocompleteProtocol", self.app)
if not getattr(app, "_autocomplete_visible", False):
return False

from sqlit.core.keymap import get_keymap

enter_accepts = any(
binding.key == "enter"
and binding.action == "autocomplete_accept"
and binding.context == "autocomplete"
for binding in get_keymap().get_action_keys()
)
dropdown = getattr(app, "autocomplete_dropdown", None)
if (
enter_accepts
and dropdown is not None
and getattr(dropdown, "filtered_items", None)
):
return await self.app.run_action("autocomplete_accept")

if hasattr(app, "_hide_autocomplete"):
app._hide_autocomplete()
app._suppress_autocomplete_on_newline = True
return False

async def _on_key(self, event: Key) -> None:
"""Intercept clipboard, undo/redo, Enter, and Tab keys."""
normalized_key = self._normalize_key(event.key)

# TextArea consumes editing keys before they reach the app-level key
# router. Forward query-insert actions explicitly so shortcuts such as
# Ctrl+Enter (and user rebindings such as F5 or Enter) execute instead
# of being interpreted as editor input.
if await self._dispatch_query_insert_action(normalized_key):
event.prevent_default()
event.stop()
return

# Tab inserts a real tab character in INSERT mode. We do this manually
# so the widget can keep the default tab_behavior='focus' and not let
# Textual's indent-aware TextArea consume Escape for focus navigation.
Expand Down Expand Up @@ -246,14 +300,13 @@ async def _on_key(self, event: Key) -> None:
# Note: Shift+Arrow selection is handled natively by TextArea
# (shift+left/right/up/down, shift+home/end)

# Handle Enter key when autocomplete is visible
if event.key == "enter":
app = cast("AutocompleteProtocol", self.app)
if getattr(app, "_autocomplete_visible", False):
# Hide autocomplete and suppress re-triggering from the newline
if hasattr(app, "_hide_autocomplete"):
app._hide_autocomplete()
app._suppress_autocomplete_on_newline = True
# Handle Enter key when autocomplete is visible. TextArea handles the
# event before the app mixin sees it, so accept here when there is an
# actual suggestion; otherwise preserve Enter's newline behaviour.
if event.key == "enter" and await self._handle_autocomplete_enter():
event.prevent_default()
event.stop()
return

# For text-modifying keys, push undo state before the change
if self._is_text_modifying_key(normalized_key):
Expand Down
140 changes: 140 additions & 0 deletions tests/ui/test_query_tab_insert.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from sqlit.core.vim import VimMode
from sqlit.domains.shell.app.keymap_manager import FileBasedKeymapProvider
from sqlit.domains.shell.app.main import SSMSTUI
from sqlit.shared.ui.widgets_autocomplete import AutocompleteItem

from .mocks import MockConnectionStore, MockSettingsStore, build_test_services

Expand Down Expand Up @@ -73,6 +74,145 @@ async def test_tab_accepts_autocomplete_suggestion(self) -> None:
assert app._autocomplete_visible is False
assert app.query_input.text == "select"

@pytest.mark.asyncio
async def test_enter_inserts_newline_with_default_tab_accept_binding(self) -> None:
app = _make_app()

async with app.run_test(size=(100, 35)) as pilot:
app.action_focus_query()
await pilot.press("i")
app.query_input.text = "sel"
app.query_input.cursor_location = (0, 3)
app._show_autocomplete(["select", "set"], "sel")
await pilot.pause()

await pilot.press("enter")
await pilot.pause()

assert app._autocomplete_visible is False
assert app.query_input.text == "sel\n"

@pytest.mark.asyncio
async def test_enter_accepts_autocomplete_when_explicitly_rebound(self) -> None:
app = _make_app()

try:
async with app.run_test(size=(100, 35)) as pilot:
app.action_focus_query()
await pilot.press("i")
defaults = DefaultKeymapProvider()
action_keys = [
binding
for binding in defaults.get_action_keys()
if not (
binding.action == "autocomplete_accept"
and binding.context == "autocomplete"
)
]
action_keys.append(
ActionKeyDef("enter", "autocomplete_accept", "autocomplete")
)
set_keymap(
FileBasedKeymapProvider(
"enter-to-accept",
defaults.get_leader_commands(),
action_keys,
)
)
app.query_input.text = "sel"
app.query_input.cursor_location = (0, 3)
app._show_autocomplete(["select", "set"], "sel")
await pilot.pause()

await pilot.press("enter")
await pilot.pause()

assert app._autocomplete_visible is False
assert app.query_input.text == "select"
finally:
reset_keymap()

@pytest.mark.asyncio
async def test_clicking_autocomplete_suggestion_applies_clicked_item(self) -> None:
app = _make_app()

async with app.run_test(size=(100, 35)) as pilot:
app.action_focus_query()
await pilot.press("i")
app.query_input.text = "se"
app.query_input.cursor_location = (0, 2)
app._show_autocomplete(["select", "set"], "se")
await pilot.pause()

items = list(app.autocomplete_dropdown.query(AutocompleteItem))
assert await pilot.click(items[1])
await pilot.pause()

assert app._autocomplete_visible is False
assert app.query_input.text == "set"

@pytest.mark.asyncio
async def test_ctrl_enter_executes_in_insert_mode_without_newline(self) -> None:
app = _make_app()
calls: list[bool] = []
app._execute_query_common = ( # type: ignore[method-assign]
lambda *, keep_insert_mode: calls.append(keep_insert_mode)
)

async with app.run_test(size=(100, 35)) as pilot:
app.action_focus_query()
await pilot.press("i")
app.query_input.text = "select 1"
await pilot.pause()

await pilot.press("ctrl+enter")
await pilot.pause()

assert calls == [True]
assert app.query_input.text == "select 1"

@pytest.mark.asyncio
async def test_enter_rebinding_executes_instead_of_inserting_newline(self) -> None:
app = _make_app()
calls: list[bool] = []
app._execute_query_common = ( # type: ignore[method-assign]
lambda *, keep_insert_mode: calls.append(keep_insert_mode)
)

try:
async with app.run_test(size=(100, 35)) as pilot:
app.action_focus_query()
await pilot.press("i")
defaults = DefaultKeymapProvider()
action_keys = [
binding
for binding in defaults.get_action_keys()
if not (
binding.action == "execute_query_insert"
and binding.context == "query_insert"
)
]
action_keys.append(
ActionKeyDef("enter", "execute_query_insert", "query_insert")
)
set_keymap(
FileBasedKeymapProvider(
"enter-to-execute",
defaults.get_leader_commands(),
action_keys,
)
)
app.query_input.text = "select 1"
await pilot.pause()

await pilot.press("enter")
await pilot.pause()

assert calls == [True]
assert app.query_input.text == "select 1"
finally:
reset_keymap()

@pytest.mark.asyncio
async def test_tab_does_not_insert_in_normal_mode(self) -> None:
app = _make_app()
Expand Down
Loading