diff --git a/sqlit/core/binding_contexts.py b/sqlit/core/binding_contexts.py index c0dc330e..a2f62aa6 100644 --- a/sqlit/core/binding_contexts.py +++ b/sqlit/core/binding_contexts.py @@ -21,6 +21,10 @@ def get_binding_contexts(ctx: InputContext) -> set[str]: contexts.add("query") if ctx.vim_mode == VimMode.INSERT: contexts.add("query_insert") + elif ctx.vim_mode == VimMode.VISUAL: + contexts.add("query_visual") + elif ctx.vim_mode == VimMode.VISUAL_LINE: + contexts.add("query_visual_line") else: contexts.add("query_normal") if ctx.autocomplete_visible: diff --git a/sqlit/core/keymap.py b/sqlit/core/keymap.py index 062441ed..64aab69a 100644 --- a/sqlit/core/keymap.py +++ b/sqlit/core/keymap.py @@ -366,9 +366,57 @@ def _build_action_keys(self) -> list[ActionKeyDef]: ActionKeyDef("F", "cursor_find_char_back", "query_normal"), ActionKeyDef("t", "cursor_till_char", "query_normal"), ActionKeyDef("T", "cursor_till_char_back", "query_normal"), + ActionKeyDef("v", "enter_visual_mode", "query_normal"), + ActionKeyDef("V", "enter_visual_line_mode", "query_normal"), ActionKeyDef("x", "delete_char", "query_normal"), ActionKeyDef("a", "append_insert_mode", "query_normal"), ActionKeyDef("A", "append_line_end", "query_normal"), + # Query (visual mode - charwise) + ActionKeyDef("escape", "exit_visual_mode", "query_visual"), + ActionKeyDef("v", "exit_visual_mode", "query_visual", primary=False), + ActionKeyDef("V", "switch_to_visual_line_mode", "query_visual"), + ActionKeyDef("y", "visual_yank", "query_visual"), + ActionKeyDef("d", "visual_delete", "query_visual"), + ActionKeyDef("x", "visual_delete", "query_visual", primary=False), + ActionKeyDef("c", "visual_change", "query_visual"), + ActionKeyDef("enter", "visual_execute", "query_visual"), + ActionKeyDef("h", "cursor_left", "query_visual"), + ActionKeyDef("j", "cursor_down", "query_visual"), + ActionKeyDef("k", "cursor_up", "query_visual"), + ActionKeyDef("l", "cursor_right", "query_visual"), + ActionKeyDef("w", "cursor_word_forward", "query_visual"), + ActionKeyDef("W", "cursor_WORD_forward", "query_visual"), + ActionKeyDef("b", "cursor_word_back", "query_visual"), + ActionKeyDef("B", "cursor_WORD_back", "query_visual"), + ActionKeyDef("0", "cursor_line_start", "query_visual"), + ActionKeyDef("circumflex_accent", "cursor_first_non_blank", "query_visual"), + ActionKeyDef("dollar_sign", "cursor_line_end", "query_visual"), + ActionKeyDef("G", "cursor_last_line", "query_visual"), + ActionKeyDef("g", "g_leader_key", "query_visual"), + ActionKeyDef("percent_sign", "cursor_matching_bracket", "query_visual"), + ActionKeyDef("f", "cursor_find_char", "query_visual"), + ActionKeyDef("F", "cursor_find_char_back", "query_visual"), + ActionKeyDef("t", "cursor_till_char", "query_visual"), + ActionKeyDef("T", "cursor_till_char_back", "query_visual"), + ActionKeyDef("down", "cursor_down", "query_visual", primary=False), + ActionKeyDef("up", "cursor_up", "query_visual", primary=False), + ActionKeyDef("left", "cursor_left", "query_visual", primary=False), + ActionKeyDef("right", "cursor_right", "query_visual", primary=False), + # Query (visual line mode) + ActionKeyDef("escape", "exit_visual_line_mode", "query_visual_line"), + ActionKeyDef("V", "exit_visual_line_mode", "query_visual_line", primary=False), + ActionKeyDef("v", "switch_to_visual_mode", "query_visual_line"), + ActionKeyDef("y", "visual_line_yank", "query_visual_line"), + ActionKeyDef("d", "visual_line_delete", "query_visual_line"), + ActionKeyDef("x", "visual_line_delete", "query_visual_line", primary=False), + ActionKeyDef("c", "visual_line_change", "query_visual_line"), + ActionKeyDef("j", "cursor_down", "query_visual_line"), + ActionKeyDef("k", "cursor_up", "query_visual_line"), + ActionKeyDef("G", "cursor_last_line", "query_visual_line"), + ActionKeyDef("g", "g_leader_key", "query_visual_line"), + ActionKeyDef("down", "cursor_down", "query_visual_line", primary=False), + ActionKeyDef("up", "cursor_up", "query_visual_line", primary=False), + ActionKeyDef("enter", "visual_line_execute", "query_visual_line"), # Query (insert mode) ActionKeyDef("escape", "exit_insert_mode", "query_insert"), ActionKeyDef("ctrl+enter", "execute_query_insert", "query_insert"), diff --git a/sqlit/core/vim.py b/sqlit/core/vim.py index d2cc969c..7b17e946 100644 --- a/sqlit/core/vim.py +++ b/sqlit/core/vim.py @@ -10,3 +10,5 @@ class VimMode(Enum): NORMAL = "NORMAL" INSERT = "INSERT" + VISUAL = "VISUAL" + VISUAL_LINE = "VISUAL LINE" diff --git a/sqlit/domains/query/state/__init__.py b/sqlit/domains/query/state/__init__.py index 103ddee2..1b6ed71f 100644 --- a/sqlit/domains/query/state/__init__.py +++ b/sqlit/domains/query/state/__init__.py @@ -4,10 +4,14 @@ from .query_focused import QueryFocusedState from .query_insert import QueryInsertModeState from .query_normal import QueryNormalModeState +from .query_visual import QueryVisualModeState +from .query_visual_line import QueryVisualLineModeState __all__ = [ "AutocompleteActiveState", "QueryFocusedState", "QueryInsertModeState", "QueryNormalModeState", + "QueryVisualModeState", + "QueryVisualLineModeState", ] diff --git a/sqlit/domains/query/state/query_normal.py b/sqlit/domains/query/state/query_normal.py index 3f9a25de..afbc9e7e 100644 --- a/sqlit/domains/query/state/query_normal.py +++ b/sqlit/domains/query/state/query_normal.py @@ -69,6 +69,9 @@ def _setup_actions(self) -> None: # Undo/redo self.allows("undo", help="Undo") self.allows("redo", help="Redo") + # Visual modes + self.allows("enter_visual_mode", label="Visual", help="Enter visual mode") + self.allows("enter_visual_line_mode", label="Visual Line", help="Enter visual line mode") def get_display_bindings(self, app: InputContext) -> tuple[list[DisplayBinding], list[DisplayBinding]]: left: list[DisplayBinding] = [] diff --git a/sqlit/domains/query/state/query_visual.py b/sqlit/domains/query/state/query_visual.py new file mode 100644 index 00000000..8e9d6021 --- /dev/null +++ b/sqlit/domains/query/state/query_visual.py @@ -0,0 +1,109 @@ +"""Query editor visual (charwise) mode state.""" + +from __future__ import annotations + +from sqlit.core.input_context import InputContext +from sqlit.core.state_base import DisplayBinding, State, resolve_display_key +from sqlit.core.vim import VimMode + + +class QueryVisualModeState(State): + """Query editor in VISUAL mode (v).""" + + help_category = "Query Editor (Visual)" + + def _setup_actions(self) -> None: + self.allows( + "exit_visual_mode", + label="Exit Visual", + help="Exit visual mode", + ) + self.forbids("enter_visual_mode") + self.forbids("enter_insert_mode") + self.forbids("delete_leader_key") + self.forbids("yank_leader_key") + self.forbids("change_leader_key") + # Switch to visual line + self.allows("switch_to_visual_line_mode", help="Switch to visual line mode") + # Visual operators + self.allows("visual_yank", label="Yank", help="Yank selection") + self.allows("visual_delete", label="Delete", help="Delete selection") + self.allows("visual_change", label="Change", help="Change selection") + self.allows("visual_execute", label="Execute", help="Execute selection") + # All cursor motions + self.allows("cursor_left", help="Move cursor left") + self.allows("cursor_right", help="Move cursor right") + self.allows("cursor_up", help="Move cursor up") + self.allows("cursor_down", help="Move cursor down") + self.allows("cursor_word_forward", help="Move to next word") + self.allows("cursor_WORD_forward", help="Move to next WORD") + self.allows("cursor_word_back", help="Move to previous word") + self.allows("cursor_WORD_back", help="Move to previous WORD") + self.allows("cursor_first_non_blank", help="Move to first non-blank") + self.allows("cursor_line_start", help="Move to line start") + self.allows("cursor_line_end", help="Move to line end") + self.allows("cursor_last_line", help="Move to last line") + self.allows("cursor_matching_bracket", help="Move to matching bracket") + self.allows("cursor_find_char", help="Find char forward") + self.allows("cursor_find_char_back", help="Find char backward") + self.allows("cursor_till_char", help="Move till char forward") + self.allows("cursor_till_char_back", help="Move till char backward") + self.allows("g_leader_key", help="Go motions (menu)") + self.allows("g_first_line", help="Go to first line") + + def get_display_bindings(self, app: InputContext) -> tuple[list[DisplayBinding], list[DisplayBinding]]: + left: list[DisplayBinding] = [] + seen: set[str] = set() + + left.append( + DisplayBinding( + key=resolve_display_key("exit_visual_mode") or "", + label="Exit Visual", + action="exit_visual_mode", + ) + ) + seen.add("exit_visual_mode") + left.append( + DisplayBinding( + key=resolve_display_key("visual_yank") or "y", + label="Yank", + action="visual_yank", + ) + ) + seen.add("visual_yank") + left.append( + DisplayBinding( + key=resolve_display_key("visual_delete") or "d", + label="Delete", + action="visual_delete", + ) + ) + seen.add("visual_delete") + left.append( + DisplayBinding( + key=resolve_display_key("visual_change") or "c", + label="Change", + action="visual_change", + ) + ) + seen.add("visual_change") + left.append( + DisplayBinding( + key=resolve_display_key("visual_execute") or "", + label="Execute", + action="visual_execute", + ) + ) + seen.add("visual_execute") + + if self.parent: + parent_left, _ = self.parent.get_display_bindings(app) + for binding in parent_left: + if binding.action not in seen: + left.append(binding) + seen.add(binding.action) + + return left, [] + + def is_active(self, app: InputContext) -> bool: + return app.focus == "query" and app.vim_mode == VimMode.VISUAL diff --git a/sqlit/domains/query/state/query_visual_line.py b/sqlit/domains/query/state/query_visual_line.py new file mode 100644 index 00000000..f409ed59 --- /dev/null +++ b/sqlit/domains/query/state/query_visual_line.py @@ -0,0 +1,114 @@ +"""Query editor visual line mode state.""" + +from __future__ import annotations + +from sqlit.core.input_context import InputContext +from sqlit.core.state_base import DisplayBinding, State, resolve_display_key +from sqlit.core.vim import VimMode + + +class QueryVisualLineModeState(State): + """Query editor in VISUAL LINE mode (V).""" + + help_category = "Query Editor (Visual Line)" + + def _setup_actions(self) -> None: + self.allows( + "exit_visual_line_mode", + label="Exit Visual", + help="Exit visual line mode", + ) + # Block entering visual line mode when already in it + self.forbids("enter_visual_line_mode") + # Switch to charwise visual + self.allows("switch_to_visual_mode", help="Switch to visual mode") + # Block normal mode operators (visual mode uses direct operators) + self.forbids("enter_insert_mode") + self.forbids("delete_leader_key") + self.forbids("yank_leader_key") + self.forbids("change_leader_key") + # Visual line operators + self.allows( + "visual_line_yank", + label="Yank", + help="Yank selected lines", + ) + self.allows( + "visual_line_delete", + label="Delete", + help="Delete selected lines", + ) + self.allows( + "visual_line_change", + label="Change", + help="Change selected lines", + ) + # Execute selected lines + self.allows( + "visual_line_execute", + label="Execute", + help="Execute selected lines", + ) + # Vertical cursor movement + self.allows("cursor_up", help="Extend selection up") + self.allows("cursor_down", help="Extend selection down") + self.allows("cursor_last_line", help="Extend selection to last line") + self.allows("g_leader_key", help="Go motions (menu)") + self.allows("g_first_line", help="Extend selection to first line") + + def get_display_bindings(self, app: InputContext) -> tuple[list[DisplayBinding], list[DisplayBinding]]: + left: list[DisplayBinding] = [] + seen: set[str] = set() + + left.append( + DisplayBinding( + key=resolve_display_key("exit_visual_line_mode") or "", + label="Exit Visual", + action="exit_visual_line_mode", + ) + ) + seen.add("exit_visual_line_mode") + left.append( + DisplayBinding( + key=resolve_display_key("visual_line_yank") or "y", + label="Yank", + action="visual_line_yank", + ) + ) + seen.add("visual_line_yank") + left.append( + DisplayBinding( + key=resolve_display_key("visual_line_delete") or "d", + label="Delete", + action="visual_line_delete", + ) + ) + seen.add("visual_line_delete") + left.append( + DisplayBinding( + key=resolve_display_key("visual_line_change") or "c", + label="Change", + action="visual_line_change", + ) + ) + seen.add("visual_line_change") + left.append( + DisplayBinding( + key=resolve_display_key("visual_line_execute") or "", + label="Execute", + action="visual_line_execute", + ) + ) + seen.add("visual_line_execute") + + if self.parent: + parent_left, _ = self.parent.get_display_bindings(app) + for binding in parent_left: + if binding.action not in seen: + left.append(binding) + seen.add(binding.action) + + return left, [] + + def is_active(self, app: InputContext) -> bool: + return app.focus == "query" and app.vim_mode == VimMode.VISUAL_LINE diff --git a/sqlit/domains/query/ui/mixins/query.py b/sqlit/domains/query/ui/mixins/query.py index 6c152dfd..53683ed9 100644 --- a/sqlit/domains/query/ui/mixins/query.py +++ b/sqlit/domains/query/ui/mixins/query.py @@ -15,11 +15,15 @@ from .query_editing_operators import QueryEditingOperatorsMixin from .query_editing_selection import QueryEditingSelectionMixin from .query_editing_undo import QueryEditingUndoMixin +from .query_editing_visual import QueryEditingVisualMixin +from .query_editing_visual_line import QueryEditingVisualLineMixin from .query_execution import QueryExecutionMixin from .query_results import QueryResultsMixin class QueryMixin( + QueryEditingVisualMixin, + QueryEditingVisualLineMixin, QueryEditingCommonMixin, QueryEditingUndoMixin, QueryEditingSelectionMixin, diff --git a/sqlit/domains/query/ui/mixins/query_editing_cursor.py b/sqlit/domains/query/ui/mixins/query_editing_cursor.py index 74dd7c5e..106fb5a5 100644 --- a/sqlit/domains/query/ui/mixins/query_editing_cursor.py +++ b/sqlit/domains/query/ui/mixins/query_editing_cursor.py @@ -20,7 +20,14 @@ def _move_with_motion(self: QueryMixinHost, motion_key: str, char: str | None = count = self._get_and_clear_count() or 1 text = self.query_input.text - row, col = self.query_input.cursor_location + # In charwise visual mode, read the logical cursor position (not the + # extended selection end) so motions operate from the correct position. + from sqlit.core.vim import VimMode as _VM + + if self.vim_mode == _VM.VISUAL and getattr(self, "_visual_cursor", None) is not None: + row, col = self._visual_cursor + else: + row, col = self.query_input.cursor_location # Apply motion `count` times for _ in range(count): @@ -31,7 +38,16 @@ def _move_with_motion(self: QueryMixinHost, motion_key: str, char: str | None = break row, col = new_row, new_col - self.query_input.cursor_location = (row, col) + # In visual modes, update the selection directly instead of setting + # cursor_location, which would clear the TextArea selection. + from sqlit.core.vim import VimMode + + if self.vim_mode == VimMode.VISUAL_LINE and hasattr(self, "_update_visual_line_selection"): + self._update_visual_line_selection(cursor_row=row) + elif self.vim_mode == VimMode.VISUAL and hasattr(self, "_update_query_visual_selection"): + self._update_query_visual_selection(cursor=(row, col)) + else: + self.query_input.cursor_location = (row, col) def action_g_leader_key(self: QueryMixinHost) -> None: """Show the g motion leader menu.""" @@ -39,6 +55,8 @@ def action_g_leader_key(self: QueryMixinHost) -> None: def action_g_first_line(self: QueryMixinHost) -> None: """Go to first line (gg), or to line N with count prefix (e.g., 3gg).""" + from sqlit.core.vim import VimMode + self._clear_leader_pending() count = self._get_and_clear_count() if count is not None: @@ -46,9 +64,15 @@ def action_g_first_line(self: QueryMixinHost) -> None: num_lines = len(lines) target_row = min(count - 1, num_lines - 1) target_row = max(0, target_row) - self.query_input.cursor_location = (target_row, 0) else: - self.query_input.cursor_location = (0, 0) + target_row = 0 + + if self.vim_mode == VimMode.VISUAL_LINE and hasattr(self, "_update_visual_line_selection"): + self._update_visual_line_selection(cursor_row=target_row) + elif self.vim_mode == VimMode.VISUAL and hasattr(self, "_update_query_visual_selection"): + self._update_query_visual_selection(cursor=(target_row, 0)) + else: + self.query_input.cursor_location = (target_row, 0) def action_g_word_end_back(self: QueryMixinHost) -> None: """Go to end of previous word (ge).""" @@ -121,6 +145,8 @@ def action_cursor_line_end(self: QueryMixinHost) -> None: def action_cursor_last_line(self: QueryMixinHost) -> None: """Move cursor to last line (G), or to line N with count prefix (e.g., 25G).""" + from sqlit.core.vim import VimMode + count = self._get_and_clear_count() if count is not None: # Go to specific line (1-indexed) @@ -128,7 +154,12 @@ def action_cursor_last_line(self: QueryMixinHost) -> None: num_lines = len(lines) target_row = min(count - 1, num_lines - 1) # Convert to 0-indexed, clamp target_row = max(0, target_row) - self.query_input.cursor_location = (target_row, 0) + if self.vim_mode == VimMode.VISUAL_LINE and hasattr(self, "_update_visual_line_selection"): + self._update_visual_line_selection(cursor_row=target_row) + elif self.vim_mode == VimMode.VISUAL and hasattr(self, "_update_query_visual_selection"): + self._update_query_visual_selection(cursor=(target_row, 0)) + else: + self.query_input.cursor_location = (target_row, 0) else: # Go to last line self._move_with_motion("G") diff --git a/sqlit/domains/query/ui/mixins/query_editing_visual.py b/sqlit/domains/query/ui/mixins/query_editing_visual.py new file mode 100644 index 00000000..a64aa80d --- /dev/null +++ b/sqlit/domains/query/ui/mixins/query_editing_visual.py @@ -0,0 +1,166 @@ +"""Visual (charwise) mode actions for query editing.""" + +from __future__ import annotations + +from sqlit.shared.ui.protocols import QueryMixinHost + + +class QueryEditingVisualMixin: + """Visual mode (v) for the query editor — charwise selection.""" + + _visual_anchor: tuple[int, int] | None = None + _visual_cursor: tuple[int, int] | None = None + + def action_enter_visual_mode(self: QueryMixinHost) -> None: + """Enter charwise visual mode (v).""" + from sqlit.core.vim import VimMode + + cursor = self.query_input.cursor_location + self._visual_anchor = cursor + self._visual_cursor = cursor + self.vim_mode = VimMode.VISUAL + self._update_query_visual_selection(cursor=cursor) + self._update_vim_mode_visuals() + self._update_footer_bindings() + + def action_exit_visual_mode(self: QueryMixinHost) -> None: + """Exit visual mode back to normal.""" + from sqlit.core.vim import VimMode + from textual.widgets.text_area import Selection + + self._visual_anchor = None + self._visual_cursor = None + self.vim_mode = VimMode.NORMAL + cursor = self.query_input.cursor_location + self.query_input.selection = Selection(cursor, cursor) + self._update_vim_mode_visuals() + self._update_footer_bindings() + + def action_switch_to_visual_line_mode(self: QueryMixinHost) -> None: + """Switch from charwise visual to visual line mode (V).""" + from sqlit.core.vim import VimMode + + cursor_row, _ = self.query_input.cursor_location + anchor = self._visual_anchor + anchor_row = anchor[0] if anchor else cursor_row + + self._visual_anchor = None + self._visual_cursor = None + self._visual_line_anchor_row = anchor_row + self.vim_mode = VimMode.VISUAL_LINE + self._update_visual_line_selection(cursor_row=cursor_row) + self._update_vim_mode_visuals() + self._update_footer_bindings() + + def action_switch_to_visual_mode(self: QueryMixinHost) -> None: + """Switch from visual line to charwise visual mode (v).""" + from sqlit.core.vim import VimMode + + cursor = self.query_input.cursor_location + anchor_row = self._visual_line_anchor_row + anchor = (anchor_row, 0) if anchor_row is not None else cursor + + self._visual_line_anchor_row = None + self._visual_anchor = anchor + self.vim_mode = VimMode.VISUAL + self._update_query_visual_selection(cursor=cursor) + self._update_vim_mode_visuals() + self._update_footer_bindings() + + def _update_query_visual_selection( + self: QueryMixinHost, cursor: tuple[int, int] | None = None + ) -> None: + """Update selection from anchor to cursor position. + + Vim visual mode is inclusive — both the anchor and cursor characters + are part of the selection. Textual's Selection is half-open (end is + exclusive), so we extend the far end by one character. + + The logical cursor position is stored in _visual_cursor so that + motion functions read the correct position (not the extended one). + """ + from textual.widgets.text_area import Selection + + anchor = self._visual_anchor + if anchor is None: + return + + if cursor is None: + cursor = self._visual_cursor or self.query_input.cursor_location + + self._visual_cursor = cursor + lines = self.query_input.text.split("\n") + + if cursor >= anchor: + # Forward: extend cursor end by 1 to include cursor char + row, col = cursor + end_col = min(col + 1, len(lines[row]) if row < len(lines) else 0) + self.query_input.selection = Selection(anchor, (row, end_col)) + else: + # Backward: extend anchor end by 1 to include anchor char + a_row, a_col = anchor + end_col = min(a_col + 1, len(lines[a_row]) if a_row < len(lines) else 0) + self.query_input.selection = Selection((a_row, end_col), cursor) + + def action_visual_yank(self: QueryMixinHost) -> None: + """Yank the charwise selection.""" + from sqlit.domains.query.editing import get_selection_text + + start, end = self._ordered_selection(self.query_input.selection) + text = get_selection_text( + self.query_input.text, start[0], start[1], end[0], end[1] + ) + if text: + self._copy_text(text) + + from sqlit.core.vim import VimMode + + self._visual_anchor = None + self._visual_cursor = None + self.vim_mode = VimMode.NORMAL + self.query_input.cursor_location = (start[0], start[1]) + + # _flash_yank_range sets the selection to the yanked range and + # schedules its own 0.15s timer to clear it back to cursor. + self._flash_yank_range(start[0], start[1], end[0], end[1]) + self._update_vim_mode_visuals() + self._update_footer_bindings() + + def action_visual_delete(self: QueryMixinHost) -> None: + """Delete the charwise selection.""" + self._push_undo_state() + self._delete_selection() + + self._visual_anchor = None + self._visual_cursor = None + + from sqlit.core.vim import VimMode + + self.vim_mode = VimMode.NORMAL + self._update_vim_mode_visuals() + self._update_footer_bindings() + + def action_visual_change(self: QueryMixinHost) -> None: + """Change (delete + insert) the charwise selection.""" + self._visual_anchor = None + self._visual_cursor = None + self._push_undo_state() + # _change_selection calls _enter_insert_mode which handles + # vim_mode, visuals, and footer updates. + self._change_selection() + + def action_visual_execute(self: QueryMixinHost) -> None: + """Execute the visually selected text.""" + self.action_execute_query() + + self._visual_anchor = None + self._visual_cursor = None + + from sqlit.core.vim import VimMode + from textual.widgets.text_area import Selection + + self.vim_mode = VimMode.NORMAL + cursor = self.query_input.cursor_location + self.query_input.selection = Selection(cursor, cursor) + self._update_vim_mode_visuals() + self._update_footer_bindings() diff --git a/sqlit/domains/query/ui/mixins/query_editing_visual_line.py b/sqlit/domains/query/ui/mixins/query_editing_visual_line.py new file mode 100644 index 00000000..edaa7848 --- /dev/null +++ b/sqlit/domains/query/ui/mixins/query_editing_visual_line.py @@ -0,0 +1,198 @@ +"""Visual line mode actions for query editing.""" + +from __future__ import annotations + +from sqlit.shared.ui.protocols import QueryMixinHost + + +class QueryEditingVisualLineMixin: + """Visual line mode (V) for the query editor.""" + + _visual_line_anchor_row: int | None = None + + def action_enter_visual_line_mode(self: QueryMixinHost) -> None: + """Enter visual line mode (V).""" + from sqlit.core.vim import VimMode + from textual.widgets.text_area import Selection + + row, _ = self.query_input.cursor_location + self._visual_line_anchor_row = row + self.vim_mode = VimMode.VISUAL_LINE + + # Select the full current line + lines = self.query_input.text.split("\n") + end_col = len(lines[row]) if row < len(lines) else 0 + self.query_input.selection = Selection((row, 0), (row, end_col)) + + self._update_vim_mode_visuals() + self._update_footer_bindings() + + def action_exit_visual_line_mode(self: QueryMixinHost) -> None: + """Exit visual line mode back to normal.""" + from sqlit.core.vim import VimMode + from textual.widgets.text_area import Selection + + self._visual_line_anchor_row = None + self.vim_mode = VimMode.NORMAL + + # Clear selection + cursor = self.query_input.cursor_location + self.query_input.selection = Selection(cursor, cursor) + + self._update_vim_mode_visuals() + self._update_footer_bindings() + + def _update_visual_line_selection( + self: QueryMixinHost, cursor_row: int | None = None + ) -> None: + """Update selection to span full lines between anchor and cursor. + + Sets the TextArea selection directly, which also positions the cursor + at the selection end. This avoids setting cursor_location separately, + which would clear the selection via TextArea internals. + """ + from textual.widgets.text_area import Selection + + anchor = self._visual_line_anchor_row + if anchor is None: + return + + if cursor_row is None: + cursor_row, _ = self.query_input.cursor_location + + lines = self.query_input.text.split("\n") + start_row = min(anchor, cursor_row) + end_row = max(anchor, cursor_row) + end_col = len(lines[end_row]) if end_row < len(lines) else 0 + + # Selection end is where the cursor lands. Place it on the cursor's side + # so the TextArea cursor follows the direction of movement. + if cursor_row >= anchor: + self.query_input.selection = Selection((start_row, 0), (end_row, end_col)) + else: + self.query_input.selection = Selection((end_row, end_col), (start_row, 0)) + + def _get_visual_line_range(self: QueryMixinHost) -> tuple[int, int]: + """Get the (start_row, end_row) of the visual line selection.""" + anchor = self._visual_line_anchor_row + if anchor is None: + row, _ = self.query_input.cursor_location + return row, row + cursor_row, _ = self.query_input.cursor_location + return min(anchor, cursor_row), max(anchor, cursor_row) + + def action_visual_line_yank(self: QueryMixinHost) -> None: + """Yank (copy) selected lines.""" + from sqlit.domains.query.editing import MotionType, Position, Range, operator_yank + + start_row, end_row = self._get_visual_line_range() + text = self.query_input.text + lines = text.split("\n") + + range_obj = Range( + Position(start_row, 0), + Position(end_row, len(lines[end_row]) if end_row < len(lines) else 0), + MotionType.LINEWISE, + ) + result = operator_yank(text, range_obj) + if result.yanked: + self._copy_text(result.yanked) + + # Exit visual line mode before flash + self._visual_line_anchor_row = None + + from sqlit.core.vim import VimMode + + self.vim_mode = VimMode.NORMAL + self.query_input.cursor_location = (start_row, 0) + + # _flash_yank_range sets the selection to the yanked range and + # schedules its own 0.15s timer to clear it back to cursor. + end_col = len(lines[end_row]) if end_row < len(lines) else 0 + self._flash_yank_range(start_row, 0, end_row, end_col) + self._update_vim_mode_visuals() + self._update_footer_bindings() + + def action_visual_line_delete(self: QueryMixinHost) -> None: + """Delete selected lines.""" + from sqlit.domains.query.editing import MotionType, Position, Range, operator_delete + from textual.widgets.text_area import Selection + + self._push_undo_state() + + start_row, end_row = self._get_visual_line_range() + text = self.query_input.text + lines = text.split("\n") + + range_obj = Range( + Position(start_row, 0), + Position(end_row, len(lines[end_row]) if end_row < len(lines) else 0), + MotionType.LINEWISE, + ) + result = operator_delete(text, range_obj) + + if result.yanked: + self._copy_text(result.yanked) + + self.query_input.text = result.text + self.query_input.cursor_location = (result.row, result.col) + + # Exit visual line mode + self._visual_line_anchor_row = None + + from sqlit.core.vim import VimMode + + self.vim_mode = VimMode.NORMAL + cursor = self.query_input.cursor_location + self.query_input.selection = Selection(cursor, cursor) + self._update_vim_mode_visuals() + self._update_footer_bindings() + + def action_visual_line_change(self: QueryMixinHost) -> None: + """Change (delete + insert mode) selected lines.""" + from sqlit.domains.query.editing import MotionType, Position, Range, operator_delete + from textual.widgets.text_area import Selection + + self._push_undo_state() + + start_row, end_row = self._get_visual_line_range() + text = self.query_input.text + lines = text.split("\n") + + range_obj = Range( + Position(start_row, 0), + Position(end_row, len(lines[end_row]) if end_row < len(lines) else 0), + MotionType.LINEWISE, + ) + result = operator_delete(text, range_obj) + + if result.yanked: + self._copy_text(result.yanked) + + self.query_input.text = result.text + self.query_input.cursor_location = (result.row, result.col) + + # Clear selection and enter insert mode + self._visual_line_anchor_row = None + cursor = self.query_input.cursor_location + self.query_input.selection = Selection(cursor, cursor) + self._enter_insert_mode() + + def action_visual_line_execute(self: QueryMixinHost) -> None: + """Execute only the visually selected lines.""" + # _get_query_to_execute already reads from self.query_input.selection, + # so we just need to trigger execution and then exit visual line mode. + # Keep the selection active during execution so _get_query_to_execute picks it up. + self.action_execute_query() + + # Exit visual line mode after triggering execution + self._visual_line_anchor_row = None + + from sqlit.core.vim import VimMode + from textual.widgets.text_area import Selection + + self.vim_mode = VimMode.NORMAL + cursor = self.query_input.cursor_location + self.query_input.selection = Selection(cursor, cursor) + self._update_vim_mode_visuals() + self._update_footer_bindings() diff --git a/sqlit/domains/shell/app/main.css b/sqlit/domains/shell/app/main.css index 1c66e54d..e416394e 100644 --- a/sqlit/domains/shell/app/main.css +++ b/sqlit/domains/shell/app/main.css @@ -136,6 +136,13 @@ color: $surface; } + #query-area.vim-visual TextArea > .text-area--cursor, + #query-area.vim-visual-line TextArea > .text-area--cursor { + /* Block cursor for VISUAL modes */ + background: $mode-normal-color; + color: $surface; + } + #results-area DataTable { height: 1fr; diff --git a/sqlit/domains/shell/state/machine.py b/sqlit/domains/shell/state/machine.py index 7a1b83a5..320528f4 100644 --- a/sqlit/domains/shell/state/machine.py +++ b/sqlit/domains/shell/state/machine.py @@ -36,6 +36,8 @@ QueryFocusedState, QueryInsertModeState, QueryNormalModeState, + QueryVisualModeState, + QueryVisualLineModeState, ) from sqlit.domains.results.state import ( ResultsFilterActiveState, @@ -73,6 +75,8 @@ def __init__(self) -> None: self.tree_on_object = TreeOnObjectState(parent=self.tree_focused) self.query_focused = QueryFocusedState(parent=self.main_screen) + self.query_visual = QueryVisualModeState(parent=self.query_focused) + self.query_visual_line = QueryVisualLineModeState(parent=self.query_focused) self.query_normal = QueryNormalModeState(parent=self.query_focused) self.query_insert = QueryInsertModeState(parent=self.query_focused) self.autocomplete_active = AutocompleteActiveState(parent=self.query_focused) @@ -96,6 +100,8 @@ def __init__(self) -> None: self.tree_on_object, # For index/trigger/sequence nodes self.tree_focused, self.autocomplete_active, # Before query_insert (more specific) + self.query_visual, # Before query_normal (more specific) + self.query_visual_line, # Before query_normal (more specific) self.query_insert, self.query_normal, self.query_focused, @@ -214,6 +220,26 @@ def binding(key: str, desc: str, indent: int = 4) -> str: lines.append(binding("^c", "Copy selection")) lines.append(binding("^v", "Paste")) lines.append("") + lines.append(subsection("Visual Mode (v):")) + lines.append(binding("/v", "Exit visual mode")) + lines.append(binding("V", "Switch to visual line mode")) + lines.append(binding("h/j/k/l", "Extend selection")) + lines.append(binding("w/b/e/$", "Extend by word/line motions")) + lines.append(binding("y", "Yank selection")) + lines.append(binding("d", "Delete selection")) + lines.append(binding("c", "Change selection")) + lines.append(binding("", "Execute selection")) + lines.append("") + lines.append(subsection("Visual Line Mode (V):")) + lines.append(binding("/V", "Exit visual line mode")) + lines.append(binding("v", "Switch to visual mode")) + lines.append(binding("j/k", "Extend selection down/up")) + lines.append(binding("gg/G", "Extend to first/last line")) + lines.append(binding("y", "Yank selected lines")) + lines.append(binding("d", "Delete selected lines")) + lines.append(binding("c", "Change selected lines")) + lines.append(binding("", "Execute selected lines")) + lines.append("") lines.append(subsection("Vim Operators (Normal Mode):")) lines.append(binding("y{motion}", "Copy")) lines.append(binding("d{motion}", "Delete")) diff --git a/sqlit/domains/shell/ui/mixins/ui_status.py b/sqlit/domains/shell/ui/mixins/ui_status.py index 55541af6..d3e33a0f 100644 --- a/sqlit/domains/shell/ui/mixins/ui_status.py +++ b/sqlit/domains/shell/ui/mixins/ui_status.py @@ -133,10 +133,14 @@ def _update_vim_mode_visuals(self: UINavigationMixinHost) -> None: # Update CSS classes for border and cursor color # Only show vim mode colors when query pane has focus - query_area.remove_class("vim-normal", "vim-insert") + query_area.remove_class("vim-normal", "vim-insert", "vim-visual", "vim-visual-line") if has_query_focus: if self.vim_mode == VimMode.NORMAL: query_area.add_class("vim-normal") + elif self.vim_mode == VimMode.VISUAL: + query_area.add_class("vim-visual") + elif self.vim_mode == VimMode.VISUAL_LINE: + query_area.add_class("vim-visual-line") else: query_area.add_class("vim-insert") @@ -213,14 +217,17 @@ def _update_status_bar(self: UINavigationMixinHost) -> None: mode_plain = "" try: if self.query_input.has_focus: + normal_color, insert_color = self._get_mode_colors() if self.vim_mode == VimMode.NORMAL: - # Warm beige background for NORMAL mode - normal_color, insert_color = self._get_mode_colors() mode_str = f"[bold #1e1e1e on {normal_color}] NORMAL [/] " mode_plain = " NORMAL " + elif self.vim_mode == VimMode.VISUAL: + mode_str = f"[bold #1e1e1e on {normal_color}] VISUAL [/] " + mode_plain = " VISUAL " + elif self.vim_mode == VimMode.VISUAL_LINE: + mode_str = f"[bold #1e1e1e on {normal_color}] V-LINE [/] " + mode_plain = " V-LINE " else: - # Soft green background for INSERT mode - normal_color, insert_color = self._get_mode_colors() mode_str = f"[bold #1e1e1e on {insert_color}] INSERT [/] " mode_plain = " INSERT " except Exception: @@ -299,7 +306,7 @@ def _update_status_bar(self: UINavigationMixinHost) -> None: try: if self.query_input.has_focus: normal_color, insert_color = self._get_mode_colors() - mode_color = normal_color if self.vim_mode == VimMode.NORMAL else insert_color + mode_color = insert_color if self.vim_mode == VimMode.INSERT else normal_color except Exception: mode_color = None @@ -458,8 +465,11 @@ def _update_footer_bindings(self: UINavigationMixinHost) -> None: normal_color, insert_color = self._get_mode_colors() key_color = normal_color - if not ctx.modal_open and ctx.focus == "query" and ctx.vim_mode == VimMode.INSERT: - key_color = insert_color + if not ctx.modal_open and ctx.focus == "query": + if ctx.vim_mode == VimMode.INSERT: + key_color = insert_color + elif ctx.vim_mode in (VimMode.VISUAL, VimMode.VISUAL_LINE): + key_color = normal_color footer.set_key_color(key_color) def _get_mode_colors(self: UINavigationMixinHost) -> tuple[str, str]: diff --git a/sqlit/shared/ui/widgets_text_area.py b/sqlit/shared/ui/widgets_text_area.py index 7eade929..2e9e1b28 100644 --- a/sqlit/shared/ui/widgets_text_area.py +++ b/sqlit/shared/ui/widgets_text_area.py @@ -200,6 +200,44 @@ async def _on_key(self, event: Key) -> None: # For all other keys, use default TextArea behavior await super()._on_key(event) + def _is_visual_mode(self) -> bool: + """Check if app is in any vim visual mode.""" + from sqlit.core.vim import VimMode + vim_mode = getattr(self.app, "vim_mode", None) + return vim_mode in (VimMode.VISUAL, VimMode.VISUAL_LINE) + + def action_cursor_up(self, select: bool = False) -> None: + """Override to delegate to app in visual modes.""" + if self._is_visual_mode(): + if hasattr(self.app, "action_cursor_up"): + self.app.action_cursor_up() + return + super().action_cursor_up(select) + + def action_cursor_down(self, select: bool = False) -> None: + """Override to delegate to app in visual modes.""" + if self._is_visual_mode(): + if hasattr(self.app, "action_cursor_down"): + self.app.action_cursor_down() + return + super().action_cursor_down(select) + + def action_cursor_left(self, select: bool = False) -> None: + """Override to delegate to app in visual modes.""" + if self._is_visual_mode(): + if hasattr(self.app, "action_cursor_left"): + self.app.action_cursor_left() + return + super().action_cursor_left(select) + + def action_cursor_right(self, select: bool = False) -> None: + """Override to delegate to app in visual modes.""" + if self._is_visual_mode(): + if hasattr(self.app, "action_cursor_right"): + self.app.action_cursor_right() + return + super().action_cursor_right(select) + def _is_text_modifying_key(self, key: str) -> bool: """Check if a key might modify text (expects normalized key).""" # Single characters, backspace, delete, enter are text-modifying diff --git a/tests/ui/keybindings/test_visual_mode.py b/tests/ui/keybindings/test_visual_mode.py new file mode 100644 index 00000000..335fcf95 --- /dev/null +++ b/tests/ui/keybindings/test_visual_mode.py @@ -0,0 +1,452 @@ +"""UI tests for vim visual mode keybindings in the query editor.""" + +from __future__ import annotations + +import pytest + +from sqlit.core.vim import VimMode +from sqlit.domains.shell.app.main import SSMSTUI + +from ..mocks import MockConnectionStore, MockSettingsStore, build_test_services + +SAMPLE_TEXT = "select\n foo,\n bar,\n baz\nfrom foo\nwhere 1=1" + + +def _make_app() -> SSMSTUI: + services = build_test_services( + connection_store=MockConnectionStore(), + settings_store=MockSettingsStore({"theme": "tokyo-night"}), + ) + return SSMSTUI(services=services) + + +class TestEnterExitVisualMode: + """Test entering and exiting visual modes.""" + + @pytest.mark.asyncio + async def test_v_enters_visual_mode(self) -> None: + app = _make_app() + + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.pause() + + app.query_input.text = SAMPLE_TEXT + app.query_input.cursor_location = (0, 3) + await pilot.pause() + + await pilot.press("v") + await pilot.pause() + assert app.vim_mode == VimMode.VISUAL + + @pytest.mark.asyncio + async def test_V_enters_visual_line_mode(self) -> None: + app = _make_app() + + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.pause() + + app.query_input.text = SAMPLE_TEXT + app.query_input.cursor_location = (0, 3) + await pilot.pause() + + await pilot.press("V") + await pilot.pause() + assert app.vim_mode == VimMode.VISUAL_LINE + + @pytest.mark.asyncio + async def test_escape_exits_visual_mode(self) -> None: + app = _make_app() + + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.pause() + + app.query_input.text = SAMPLE_TEXT + await pilot.pause() + + await pilot.press("v") + await pilot.pause() + assert app.vim_mode == VimMode.VISUAL + + await pilot.press("escape") + await pilot.pause() + assert app.vim_mode == VimMode.NORMAL + + @pytest.mark.asyncio + async def test_escape_exits_visual_line_mode(self) -> None: + app = _make_app() + + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.pause() + + app.query_input.text = SAMPLE_TEXT + await pilot.pause() + + await pilot.press("V") + await pilot.pause() + assert app.vim_mode == VimMode.VISUAL_LINE + + await pilot.press("escape") + await pilot.pause() + assert app.vim_mode == VimMode.NORMAL + + @pytest.mark.asyncio + async def test_v_exits_visual_mode(self) -> None: + """Pressing v again in visual mode should exit.""" + app = _make_app() + + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.pause() + + app.query_input.text = SAMPLE_TEXT + await pilot.pause() + + await pilot.press("v") + await pilot.pause() + assert app.vim_mode == VimMode.VISUAL + + await pilot.press("v") + await pilot.pause() + assert app.vim_mode == VimMode.NORMAL + + @pytest.mark.asyncio + async def test_V_exits_visual_line_mode(self) -> None: + """Pressing V again in visual line mode should exit.""" + app = _make_app() + + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.pause() + + app.query_input.text = SAMPLE_TEXT + await pilot.pause() + + await pilot.press("V") + await pilot.pause() + assert app.vim_mode == VimMode.VISUAL_LINE + + await pilot.press("V") + await pilot.pause() + assert app.vim_mode == VimMode.NORMAL + + +class TestVisualModeToggle: + """Test toggling between visual and visual line modes.""" + + @pytest.mark.asyncio + async def test_V_in_visual_switches_to_visual_line(self) -> None: + app = _make_app() + + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.pause() + + app.query_input.text = SAMPLE_TEXT + await pilot.pause() + + await pilot.press("v") + await pilot.pause() + assert app.vim_mode == VimMode.VISUAL + + await pilot.press("V") + await pilot.pause() + assert app.vim_mode == VimMode.VISUAL_LINE + + @pytest.mark.asyncio + async def test_v_in_visual_line_switches_to_visual(self) -> None: + app = _make_app() + + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.pause() + + app.query_input.text = SAMPLE_TEXT + await pilot.pause() + + await pilot.press("V") + await pilot.pause() + assert app.vim_mode == VimMode.VISUAL_LINE + + await pilot.press("v") + await pilot.pause() + assert app.vim_mode == VimMode.VISUAL + + +class TestVisualModeMotions: + """Test that motions extend selection in visual mode.""" + + @pytest.mark.asyncio + async def test_visual_mode_h_l_movement(self) -> None: + app = _make_app() + + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.pause() + + app.query_input.text = "hello world" + app.query_input.cursor_location = (0, 5) + await pilot.pause() + + await pilot.press("v") + await pilot.pause() + + await pilot.press("l") + await pilot.pause() + + sel = app.query_input.selection + assert sel.start != sel.end, "Selection should be non-empty after motion" + + @pytest.mark.asyncio + async def test_visual_mode_w_extends_selection(self) -> None: + app = _make_app() + + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.pause() + + app.query_input.text = "hello world" + app.query_input.cursor_location = (0, 0) + await pilot.pause() + + await pilot.press("v") + await pilot.pause() + + await pilot.press("w") + await pilot.pause() + + sel = app.query_input.selection + assert sel.start != sel.end + assert app.vim_mode == VimMode.VISUAL + + @pytest.mark.asyncio + async def test_visual_line_mode_j_k_movement(self) -> None: + """j/k should extend selection by full lines in visual line mode.""" + app = _make_app() + + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.pause() + + app.query_input.text = SAMPLE_TEXT + app.query_input.cursor_location = (1, 0) + await pilot.pause() + + await pilot.press("V") + await pilot.pause() + + # Move down + await pilot.press("j") + await pilot.pause() + + sel = app.query_input.selection + start, end = min(sel.start, sel.end), max(sel.start, sel.end) + # Should span from row 1 col 0 to row 2 end + assert start[0] == 1 + assert start[1] == 0 + assert end[0] == 2 + + # Move back up + await pilot.press("k") + await pilot.pause() + + sel = app.query_input.selection + start, end = min(sel.start, sel.end), max(sel.start, sel.end) + assert start[0] == 1 + assert end[0] == 1 + + @pytest.mark.asyncio + async def test_visual_line_mode_G_extends_to_end(self) -> None: + app = _make_app() + + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.pause() + + app.query_input.text = SAMPLE_TEXT + app.query_input.cursor_location = (0, 0) + await pilot.pause() + + await pilot.press("V") + await pilot.pause() + + await pilot.press("G") + await pilot.pause() + + sel = app.query_input.selection + start, end = min(sel.start, sel.end), max(sel.start, sel.end) + assert start[0] == 0 + last_row = SAMPLE_TEXT.count("\n") + assert end[0] == last_row + + +class TestVisualModeOperators: + """Test operators (y, d, c) in visual modes.""" + + @pytest.mark.asyncio + async def test_visual_yank(self) -> None: + app = _make_app() + + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.pause() + + app.query_input.text = "hello world" + app.query_input.cursor_location = (0, 0) + app._internal_clipboard = "" + await pilot.pause() + + await pilot.press("v") + await pilot.pause() + + # Select "hello w" — w moves to col 6, inclusive of cursor char + await pilot.press("w") + await pilot.pause() + + await pilot.press("y") + await pilot.pause() + + assert app.vim_mode == VimMode.NORMAL + assert app._internal_clipboard == "hello w" + # Text should be unchanged + assert app.query_input.text == "hello world" + + @pytest.mark.asyncio + async def test_visual_delete(self) -> None: + app = _make_app() + + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.pause() + + app.query_input.text = "hello world" + app.query_input.cursor_location = (0, 0) + await pilot.pause() + + await pilot.press("v") + await pilot.pause() + + await pilot.press("w") + await pilot.pause() + + await pilot.press("d") + await pilot.pause() + + assert app.vim_mode == VimMode.NORMAL + assert app.query_input.text == "orld" + + @pytest.mark.asyncio + async def test_visual_change_enters_insert(self) -> None: + app = _make_app() + + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.pause() + + app.query_input.text = "hello world" + app.query_input.cursor_location = (0, 0) + await pilot.pause() + + await pilot.press("v") + await pilot.pause() + + await pilot.press("w") + await pilot.pause() + + await pilot.press("c") + await pilot.pause() + + assert app.vim_mode == VimMode.INSERT + assert app.query_input.text == "orld" + + @pytest.mark.asyncio + async def test_visual_line_yank(self) -> None: + app = _make_app() + + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.pause() + + app.query_input.text = "alpha\nbeta\ngamma" + app.query_input.cursor_location = (1, 0) + app._internal_clipboard = "" + await pilot.pause() + + await pilot.press("V") + await pilot.pause() + + await pilot.press("y") + await pilot.pause() + + assert app.vim_mode == VimMode.NORMAL + assert app._internal_clipboard == "beta" + assert app.query_input.text == "alpha\nbeta\ngamma" + + @pytest.mark.asyncio + async def test_visual_line_delete(self) -> None: + app = _make_app() + + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.pause() + + app.query_input.text = "alpha\nbeta\ngamma" + app.query_input.cursor_location = (1, 0) + await pilot.pause() + + await pilot.press("V") + await pilot.pause() + + await pilot.press("d") + await pilot.pause() + + assert app.vim_mode == VimMode.NORMAL + assert app.query_input.text == "alpha\ngamma" + + @pytest.mark.asyncio + async def test_visual_line_delete_multiple_lines(self) -> None: + app = _make_app() + + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.pause() + + app.query_input.text = "alpha\nbeta\ngamma\ndelta" + app.query_input.cursor_location = (1, 0) + await pilot.pause() + + await pilot.press("V") + await pilot.pause() + + await pilot.press("j") + await pilot.pause() + + await pilot.press("d") + await pilot.pause() + + assert app.vim_mode == VimMode.NORMAL + assert app.query_input.text == "alpha\ndelta" + + @pytest.mark.asyncio + async def test_visual_line_change_enters_insert(self) -> None: + app = _make_app() + + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.pause() + + app.query_input.text = "alpha\nbeta\ngamma" + app.query_input.cursor_location = (1, 0) + await pilot.pause() + + await pilot.press("V") + await pilot.pause() + + await pilot.press("c") + await pilot.pause() + + assert app.vim_mode == VimMode.INSERT + assert app.query_input.text == "alpha\ngamma" diff --git a/tests/unit/test_vim_visual_mode.py b/tests/unit/test_vim_visual_mode.py new file mode 100644 index 00000000..5ddc4719 --- /dev/null +++ b/tests/unit/test_vim_visual_mode.py @@ -0,0 +1,238 @@ +"""Unit tests for visual mode selection logic and state machine wiring.""" + +from __future__ import annotations + +from sqlit.core.binding_contexts import get_binding_contexts +from sqlit.core.input_context import InputContext +from sqlit.core.key_router import resolve_action +from sqlit.core.vim import VimMode +from sqlit.domains.shell.state import UIStateMachine + + +def make_context(**overrides: object) -> InputContext: + """Build a default InputContext with optional overrides.""" + data = { + "focus": "none", + "vim_mode": VimMode.NORMAL, + "leader_pending": False, + "leader_menu": "leader", + "tree_filter_active": False, + "tree_multi_select_active": False, + "tree_visual_mode_active": False, + "autocomplete_visible": False, + "results_filter_active": False, + "value_view_active": False, + "value_view_tree_mode": False, + "value_view_is_json": False, + "query_executing": False, + "modal_open": False, + "has_connection": False, + "current_connection_name": None, + "tree_node_kind": None, + "tree_node_connection_name": None, + "tree_node_connection_selected": False, + "last_result_is_error": False, + "has_results": False, + } + data.update(overrides) + return InputContext(**data) + + +class TestBindingContexts: + """Test that binding contexts resolve correctly for visual modes.""" + + def test_visual_mode_context(self) -> None: + ctx = make_context(focus="query", vim_mode=VimMode.VISUAL) + contexts = get_binding_contexts(ctx) + assert "query_visual" in contexts + assert "query_normal" not in contexts + assert "query_visual_line" not in contexts + + def test_visual_line_mode_context(self) -> None: + ctx = make_context(focus="query", vim_mode=VimMode.VISUAL_LINE) + contexts = get_binding_contexts(ctx) + assert "query_visual_line" in contexts + assert "query_normal" not in contexts + assert "query_visual" not in contexts + + def test_normal_mode_context_unchanged(self) -> None: + ctx = make_context(focus="query", vim_mode=VimMode.NORMAL) + contexts = get_binding_contexts(ctx) + assert "query_normal" in contexts + assert "query_visual" not in contexts + assert "query_visual_line" not in contexts + + +class TestVisualModeStateMachine: + """Test state machine action validation for visual modes.""" + + def test_enter_visual_mode_allowed_in_normal(self) -> None: + sm = UIStateMachine() + ctx = make_context(focus="query", vim_mode=VimMode.NORMAL) + assert sm.check_action(ctx, "enter_visual_mode") is True + + def test_enter_visual_line_mode_allowed_in_normal(self) -> None: + sm = UIStateMachine() + ctx = make_context(focus="query", vim_mode=VimMode.NORMAL) + assert sm.check_action(ctx, "enter_visual_line_mode") is True + + def test_enter_visual_mode_blocked_in_visual(self) -> None: + sm = UIStateMachine() + ctx = make_context(focus="query", vim_mode=VimMode.VISUAL) + assert sm.check_action(ctx, "enter_visual_mode") is False + + def test_enter_visual_line_blocked_in_visual_line(self) -> None: + sm = UIStateMachine() + ctx = make_context(focus="query", vim_mode=VimMode.VISUAL_LINE) + assert sm.check_action(ctx, "enter_visual_line_mode") is False + + def test_insert_mode_blocked_in_visual(self) -> None: + sm = UIStateMachine() + ctx = make_context(focus="query", vim_mode=VimMode.VISUAL) + assert sm.check_action(ctx, "enter_insert_mode") is False + + def test_insert_mode_blocked_in_visual_line(self) -> None: + sm = UIStateMachine() + ctx = make_context(focus="query", vim_mode=VimMode.VISUAL_LINE) + assert sm.check_action(ctx, "enter_insert_mode") is False + + def test_switch_to_visual_line_allowed_in_visual(self) -> None: + sm = UIStateMachine() + ctx = make_context(focus="query", vim_mode=VimMode.VISUAL) + assert sm.check_action(ctx, "switch_to_visual_line_mode") is True + + def test_switch_to_visual_allowed_in_visual_line(self) -> None: + sm = UIStateMachine() + ctx = make_context(focus="query", vim_mode=VimMode.VISUAL_LINE) + assert sm.check_action(ctx, "switch_to_visual_mode") is True + + +class TestVisualModeOperatorActions: + """Test that operators are allowed in visual modes and blocked elsewhere.""" + + def test_visual_operators_allowed(self) -> None: + sm = UIStateMachine() + ctx = make_context(focus="query", vim_mode=VimMode.VISUAL) + for action in ["visual_yank", "visual_delete", "visual_change", "visual_execute"]: + assert sm.check_action(ctx, action) is True, f"{action} should be allowed" + + def test_visual_line_operators_allowed(self) -> None: + sm = UIStateMachine() + ctx = make_context(focus="query", vim_mode=VimMode.VISUAL_LINE) + for action in ["visual_line_yank", "visual_line_delete", "visual_line_change", "visual_line_execute"]: + assert sm.check_action(ctx, action) is True, f"{action} should be allowed" + + def test_leader_operators_blocked_in_visual(self) -> None: + sm = UIStateMachine() + ctx = make_context(focus="query", vim_mode=VimMode.VISUAL) + for action in ["delete_leader_key", "yank_leader_key", "change_leader_key"]: + assert sm.check_action(ctx, action) is False, f"{action} should be blocked" + + def test_leader_operators_blocked_in_visual_line(self) -> None: + sm = UIStateMachine() + ctx = make_context(focus="query", vim_mode=VimMode.VISUAL_LINE) + for action in ["delete_leader_key", "yank_leader_key", "change_leader_key"]: + assert sm.check_action(ctx, action) is False, f"{action} should be blocked" + + +class TestVisualModeMotionActions: + """Test that motions are allowed in the correct visual modes.""" + + def test_all_motions_allowed_in_visual(self) -> None: + sm = UIStateMachine() + ctx = make_context(focus="query", vim_mode=VimMode.VISUAL) + motions = [ + "cursor_left", "cursor_right", "cursor_up", "cursor_down", + "cursor_word_forward", "cursor_word_back", + "cursor_line_start", "cursor_line_end", "cursor_last_line", + "cursor_matching_bracket", "cursor_find_char", "cursor_find_char_back", + ] + for action in motions: + assert sm.check_action(ctx, action) is True, f"{action} should be allowed" + + def test_vertical_motions_allowed_in_visual_line(self) -> None: + sm = UIStateMachine() + ctx = make_context(focus="query", vim_mode=VimMode.VISUAL_LINE) + for action in ["cursor_up", "cursor_down", "cursor_last_line", "g_leader_key", "g_first_line"]: + assert sm.check_action(ctx, action) is True, f"{action} should be allowed" + + +class TestVisualModeKeyRouting: + """Test that keys resolve to correct actions in visual modes.""" + + def test_visual_mode_key_routing(self) -> None: + sm = UIStateMachine() + ctx = make_context(focus="query", vim_mode=VimMode.VISUAL) + is_allowed = lambda name: sm.check_action(ctx, name) + + expected = { + "h": "cursor_left", + "j": "cursor_down", + "k": "cursor_up", + "l": "cursor_right", + "w": "cursor_word_forward", + "b": "cursor_word_back", + "y": "visual_yank", + "d": "visual_delete", + "c": "visual_change", + "V": "switch_to_visual_line_mode", + "escape": "exit_visual_mode", + "enter": "visual_execute", + } + for key, action in expected.items(): + result = resolve_action(key, ctx, is_allowed=is_allowed) + assert result == action, f"key '{key}' should resolve to '{action}', got '{result}'" + + def test_visual_line_mode_key_routing(self) -> None: + sm = UIStateMachine() + ctx = make_context(focus="query", vim_mode=VimMode.VISUAL_LINE) + is_allowed = lambda name: sm.check_action(ctx, name) + + expected = { + "j": "cursor_down", + "k": "cursor_up", + "G": "cursor_last_line", + "y": "visual_line_yank", + "d": "visual_line_delete", + "c": "visual_line_change", + "v": "switch_to_visual_mode", + "escape": "exit_visual_line_mode", + "enter": "visual_line_execute", + } + for key, action in expected.items(): + result = resolve_action(key, ctx, is_allowed=is_allowed) + assert result == action, f"key '{key}' should resolve to '{action}', got '{result}'" + + def test_normal_mode_entry_keys(self) -> None: + sm = UIStateMachine() + ctx = make_context(focus="query", vim_mode=VimMode.NORMAL) + is_allowed = lambda name: sm.check_action(ctx, name) + + assert resolve_action("v", ctx, is_allowed=is_allowed) == "enter_visual_mode" + assert resolve_action("V", ctx, is_allowed=is_allowed) == "enter_visual_line_mode" + + +class TestVisualModeFooterBindings: + """Test that footer displays correct bindings in visual modes.""" + + def test_visual_mode_footer(self) -> None: + sm = UIStateMachine() + ctx = make_context(focus="query", vim_mode=VimMode.VISUAL) + left, _ = sm.get_display_bindings(ctx) + actions = {b.action for b in left} + assert "exit_visual_mode" in actions + assert "visual_yank" in actions + assert "visual_delete" in actions + assert "visual_change" in actions + assert "visual_execute" in actions + + def test_visual_line_mode_footer(self) -> None: + sm = UIStateMachine() + ctx = make_context(focus="query", vim_mode=VimMode.VISUAL_LINE) + left, _ = sm.get_display_bindings(ctx) + actions = {b.action for b in left} + assert "exit_visual_line_mode" in actions + assert "visual_line_yank" in actions + assert "visual_line_delete" in actions + assert "visual_line_change" in actions + assert "visual_line_execute" in actions