From c591ba6a423e490f2aaf47c5c20cce9e71946d15 Mon Sep 17 00:00:00 2001 From: Charles Lariviere Date: Wed, 1 Apr 2026 20:46:07 -0400 Subject: [PATCH 1/8] Add vim Visual Line mode (V) to query editor Adds Shift+V visual line mode for selecting entire lines in the query editor, matching vim's native behavior. Selected lines can be yanked (y), deleted (d), changed (c), or executed (Enter). - New VimMode.VISUAL_LINE enum value and query_visual_line binding context - QueryVisualLineModeState for action validation and footer bindings - QueryEditingVisualLineMixin with enter/exit, selection tracking, and line-wise yank/delete/change/execute operators - Selection updates via TextArea.selection directly to avoid cursor_location clearing the highlight - Arrow key support via QueryTextArea action overrides - V-LINE mode indicator in status bar and help text Co-Authored-By: Claude Opus 4.6 (1M context) --- sqlit/core/binding_contexts.py | 2 + sqlit/core/keymap.py | 15 ++ sqlit/core/vim.py | 1 + sqlit/domains/query/state/__init__.py | 2 + sqlit/domains/query/state/query_normal.py | 2 + .../domains/query/state/query_visual_line.py | 112 ++++++++++ sqlit/domains/query/ui/mixins/query.py | 2 + .../query/ui/mixins/query_editing_cursor.py | 26 ++- .../ui/mixins/query_editing_visual_line.py | 204 ++++++++++++++++++ sqlit/domains/shell/app/main.css | 6 + sqlit/domains/shell/state/machine.py | 12 ++ sqlit/domains/shell/ui/mixins/ui_status.py | 19 +- sqlit/shared/ui/widgets_text_area.py | 22 ++ 13 files changed, 414 insertions(+), 11 deletions(-) create mode 100644 sqlit/domains/query/state/query_visual_line.py create mode 100644 sqlit/domains/query/ui/mixins/query_editing_visual_line.py diff --git a/sqlit/core/binding_contexts.py b/sqlit/core/binding_contexts.py index c0dc330e..5f71f757 100644 --- a/sqlit/core/binding_contexts.py +++ b/sqlit/core/binding_contexts.py @@ -21,6 +21,8 @@ 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_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..f0d2efb6 100644 --- a/sqlit/core/keymap.py +++ b/sqlit/core/keymap.py @@ -366,9 +366,24 @@ 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_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 line mode) + ActionKeyDef("escape", "exit_visual_line_mode", "query_visual_line"), + ActionKeyDef("V", "exit_visual_line_mode", "query_visual_line", primary=False), + ActionKeyDef("v", "exit_visual_line_mode", "query_visual_line", primary=False), + ActionKeyDef("y", "visual_line_yank", "query_visual_line"), + ActionKeyDef("d", "visual_line_delete", "query_visual_line"), + 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..bb4ff581 100644 --- a/sqlit/core/vim.py +++ b/sqlit/core/vim.py @@ -10,3 +10,4 @@ class VimMode(Enum): NORMAL = "NORMAL" INSERT = "INSERT" + VISUAL_LINE = "VISUAL LINE" diff --git a/sqlit/domains/query/state/__init__.py b/sqlit/domains/query/state/__init__.py index 103ddee2..afccb3b6 100644 --- a/sqlit/domains/query/state/__init__.py +++ b/sqlit/domains/query/state/__init__.py @@ -4,10 +4,12 @@ from .query_focused import QueryFocusedState from .query_insert import QueryInsertModeState from .query_normal import QueryNormalModeState +from .query_visual_line import QueryVisualLineModeState __all__ = [ "AutocompleteActiveState", "QueryFocusedState", "QueryInsertModeState", "QueryNormalModeState", + "QueryVisualLineModeState", ] diff --git a/sqlit/domains/query/state/query_normal.py b/sqlit/domains/query/state/query_normal.py index 3f9a25de..4ab30410 100644 --- a/sqlit/domains/query/state/query_normal.py +++ b/sqlit/domains/query/state/query_normal.py @@ -69,6 +69,8 @@ def _setup_actions(self) -> None: # Undo/redo self.allows("undo", help="Undo") self.allows("redo", help="Redo") + # Visual line 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_line.py b/sqlit/domains/query/state/query_visual_line.py new file mode 100644 index 00000000..8a5c7a01 --- /dev/null +++ b/sqlit/domains/query/state/query_visual_line.py @@ -0,0 +1,112 @@ +"""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 mode when already in it + self.forbids("enter_visual_line_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..bf222bf7 100644 --- a/sqlit/domains/query/ui/mixins/query.py +++ b/sqlit/domains/query/ui/mixins/query.py @@ -15,11 +15,13 @@ from .query_editing_operators import QueryEditingOperatorsMixin from .query_editing_selection import QueryEditingSelectionMixin from .query_editing_undo import QueryEditingUndoMixin +from .query_editing_visual_line import QueryEditingVisualLineMixin from .query_execution import QueryExecutionMixin from .query_results import QueryResultsMixin class QueryMixin( + 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..4c47e29a 100644 --- a/sqlit/domains/query/ui/mixins/query_editing_cursor.py +++ b/sqlit/domains/query/ui/mixins/query_editing_cursor.py @@ -31,7 +31,14 @@ 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 line mode, update the visual 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) + else: + self.query_input.cursor_location = (row, col) def action_g_leader_key(self: QueryMixinHost) -> None: """Show the g motion leader menu.""" @@ -39,6 +46,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 +55,13 @@ 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) + 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 +134,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 +143,10 @@ 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) + 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_line.py b/sqlit/domains/query/ui/mixins/query_editing_visual_line.py new file mode 100644 index 00000000..5ba092fb --- /dev/null +++ b/sqlit/domains/query/ui/mixins/query_editing_visual_line.py @@ -0,0 +1,204 @@ +"""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) + + # Flash the selection then exit + end_col = len(lines[end_row]) if end_row < len(lines) else 0 + self._flash_yank_range(start_row, 0, end_row, end_col) + + # Exit visual line mode (after flash timer starts) + self._visual_line_anchor_row = None + + from sqlit.core.vim import VimMode + from textual.widgets.text_area import Selection + + self.vim_mode = VimMode.NORMAL + self.query_input.cursor_location = (start_row, 0) + + def _clear_and_update() -> None: + cursor = self.query_input.cursor_location + self.query_input.selection = Selection(cursor, cursor) + + self.set_timer(0.15, _clear_and_update) + 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..9444ab36 100644 --- a/sqlit/domains/shell/app/main.css +++ b/sqlit/domains/shell/app/main.css @@ -136,6 +136,12 @@ color: $surface; } + #query-area.vim-visual-line TextArea > .text-area--cursor { + /* Block cursor for VISUAL LINE mode */ + 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..532af46e 100644 --- a/sqlit/domains/shell/state/machine.py +++ b/sqlit/domains/shell/state/machine.py @@ -36,6 +36,7 @@ QueryFocusedState, QueryInsertModeState, QueryNormalModeState, + QueryVisualLineModeState, ) from sqlit.domains.results.state import ( ResultsFilterActiveState, @@ -73,6 +74,7 @@ def __init__(self) -> None: self.tree_on_object = TreeOnObjectState(parent=self.tree_focused) self.query_focused = QueryFocusedState(parent=self.main_screen) + 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 +98,7 @@ 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_line, # Before query_normal (more specific) self.query_insert, self.query_normal, self.query_focused, @@ -214,6 +217,15 @@ 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 Line Mode (V):")) + lines.append(binding("", "Exit 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..0578a5c3 100644 --- a/sqlit/domains/shell/ui/mixins/ui_status.py +++ b/sqlit/domains/shell/ui/mixins/ui_status.py @@ -133,10 +133,12 @@ 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-line") if has_query_focus: if self.vim_mode == VimMode.NORMAL: query_area.add_class("vim-normal") + elif self.vim_mode == VimMode.VISUAL_LINE: + query_area.add_class("vim-visual-line") else: query_area.add_class("vim-insert") @@ -213,14 +215,14 @@ 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_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: @@ -458,8 +460,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 == 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..8cf53f9e 100644 --- a/sqlit/shared/ui/widgets_text_area.py +++ b/sqlit/shared/ui/widgets_text_area.py @@ -200,6 +200,28 @@ async def _on_key(self, event: Key) -> None: # For all other keys, use default TextArea behavior await super()._on_key(event) + def _is_visual_line_mode(self) -> bool: + """Check if app is in vim VISUAL LINE mode.""" + from sqlit.core.vim import VimMode + vim_mode = getattr(self.app, "vim_mode", None) + return vim_mode == VimMode.VISUAL_LINE + + def action_cursor_up(self, select: bool = False) -> None: + """Override to delegate to app in visual line mode.""" + if self._is_visual_line_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 line mode.""" + if self._is_visual_line_mode(): + if hasattr(self.app, "action_cursor_down"): + self.app.action_cursor_down() + return + super().action_cursor_down(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 From 650dbec8f28b8d9175a6ffb923daffd746f77c10 Mon Sep 17 00:00:00 2001 From: Charles Lariviere Date: Wed, 1 Apr 2026 21:01:24 -0400 Subject: [PATCH 2/8] Add vim visual modes (v, V) to query editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds both charwise visual mode (v) and visual line mode (V) to the query editor, matching vim's native behavior. Visual mode (v): character-wise selection using all standard motions (h/j/k/l, w/b, 0/$, f/t, G/gg, %, etc). Operators y/d/c act on the exact selection, Enter executes selected text. Visual line mode (V): selects entire lines, j/k/G/gg extend by full lines. Operators act linewise. Both modes support toggling between each other (v↔V), arrow key navigation, and display a VISUAL/V-LINE indicator in the status bar. Co-Authored-By: Claude Opus 4.6 (1M context) --- sqlit/core/binding_contexts.py | 2 + sqlit/core/keymap.py | 33 ++++- sqlit/core/vim.py | 1 + sqlit/domains/query/state/__init__.py | 2 + sqlit/domains/query/state/query_normal.py | 3 +- sqlit/domains/query/state/query_visual.py | 109 ++++++++++++++ .../domains/query/state/query_visual_line.py | 4 +- sqlit/domains/query/ui/mixins/query.py | 2 + .../query/ui/mixins/query_editing_cursor.py | 10 +- .../query/ui/mixins/query_editing_visual.py | 139 ++++++++++++++++++ sqlit/domains/shell/app/main.css | 3 +- sqlit/domains/shell/state/machine.py | 16 +- sqlit/domains/shell/ui/mixins/ui_status.py | 9 +- sqlit/shared/ui/widgets_text_area.py | 30 +++- 14 files changed, 347 insertions(+), 16 deletions(-) create mode 100644 sqlit/domains/query/state/query_visual.py create mode 100644 sqlit/domains/query/ui/mixins/query_editing_visual.py diff --git a/sqlit/core/binding_contexts.py b/sqlit/core/binding_contexts.py index 5f71f757..a2f62aa6 100644 --- a/sqlit/core/binding_contexts.py +++ b/sqlit/core/binding_contexts.py @@ -21,6 +21,8 @@ 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: diff --git a/sqlit/core/keymap.py b/sqlit/core/keymap.py index f0d2efb6..5f6c08f1 100644 --- a/sqlit/core/keymap.py +++ b/sqlit/core/keymap.py @@ -366,14 +366,45 @@ 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("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", "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("c", "visual_line_change", "query_visual_line"), diff --git a/sqlit/core/vim.py b/sqlit/core/vim.py index bb4ff581..7b17e946 100644 --- a/sqlit/core/vim.py +++ b/sqlit/core/vim.py @@ -10,4 +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 afccb3b6..1b6ed71f 100644 --- a/sqlit/domains/query/state/__init__.py +++ b/sqlit/domains/query/state/__init__.py @@ -4,6 +4,7 @@ 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__ = [ @@ -11,5 +12,6 @@ "QueryFocusedState", "QueryInsertModeState", "QueryNormalModeState", + "QueryVisualModeState", "QueryVisualLineModeState", ] diff --git a/sqlit/domains/query/state/query_normal.py b/sqlit/domains/query/state/query_normal.py index 4ab30410..afbc9e7e 100644 --- a/sqlit/domains/query/state/query_normal.py +++ b/sqlit/domains/query/state/query_normal.py @@ -69,7 +69,8 @@ def _setup_actions(self) -> None: # Undo/redo self.allows("undo", help="Undo") self.allows("redo", help="Redo") - # Visual line mode + # 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]]: 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 index 8a5c7a01..f409ed59 100644 --- a/sqlit/domains/query/state/query_visual_line.py +++ b/sqlit/domains/query/state/query_visual_line.py @@ -18,8 +18,10 @@ def _setup_actions(self) -> None: label="Exit Visual", help="Exit visual line mode", ) - # Block entering visual mode when already in it + # 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") diff --git a/sqlit/domains/query/ui/mixins/query.py b/sqlit/domains/query/ui/mixins/query.py index bf222bf7..53683ed9 100644 --- a/sqlit/domains/query/ui/mixins/query.py +++ b/sqlit/domains/query/ui/mixins/query.py @@ -15,12 +15,14 @@ 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, diff --git a/sqlit/domains/query/ui/mixins/query_editing_cursor.py b/sqlit/domains/query/ui/mixins/query_editing_cursor.py index 4c47e29a..b00ff738 100644 --- a/sqlit/domains/query/ui/mixins/query_editing_cursor.py +++ b/sqlit/domains/query/ui/mixins/query_editing_cursor.py @@ -31,12 +31,14 @@ def _move_with_motion(self: QueryMixinHost, motion_key: str, char: str | None = break row, col = new_row, new_col - # In visual line mode, update the visual selection directly instead of - # setting cursor_location, which would clear the TextArea selection. + # 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_visual_selection"): + self._update_visual_selection(cursor=(row, col)) else: self.query_input.cursor_location = (row, col) @@ -60,6 +62,8 @@ def action_g_first_line(self: QueryMixinHost) -> None: 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_visual_selection"): + self._update_visual_selection(cursor=(target_row, 0)) else: self.query_input.cursor_location = (target_row, 0) @@ -145,6 +149,8 @@ def action_cursor_last_line(self: QueryMixinHost) -> None: target_row = max(0, target_row) 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_visual_selection"): + self._update_visual_selection(cursor=(target_row, 0)) else: self.query_input.cursor_location = (target_row, 0) else: 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..acb3c67a --- /dev/null +++ b/sqlit/domains/query/ui/mixins/query_editing_visual.py @@ -0,0 +1,139 @@ +"""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 + + def action_enter_visual_mode(self: QueryMixinHost) -> None: + """Enter charwise visual mode (v).""" + from sqlit.core.vim import VimMode + + self._visual_anchor = self.query_input.cursor_location + self.vim_mode = VimMode.VISUAL + 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.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_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_visual_selection(cursor=cursor) + self._update_vim_mode_visuals() + self._update_footer_bindings() + + def _update_visual_selection( + self: QueryMixinHost, cursor: tuple[int, int] | None = None + ) -> None: + """Update selection from anchor to cursor position.""" + from textual.widgets.text_area import Selection + + anchor = self._visual_anchor + if anchor is None: + return + + if cursor is None: + cursor = self.query_input.cursor_location + + self.query_input.selection = Selection(anchor, 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) + + self._flash_yank_range(start[0], start[1], end[0], end[1]) + + from sqlit.core.vim import VimMode + from textual.widgets.text_area import Selection + + self._visual_anchor = None + self.vim_mode = VimMode.NORMAL + self.query_input.cursor_location = (start[0], start[1]) + + def _clear() -> None: + cur = self.query_input.cursor_location + self.query_input.selection = Selection(cur, cur) + + self.set_timer(0.15, _clear) + 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 + + 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._push_undo_state() + self._change_selection() + self._visual_anchor = None + + def action_visual_execute(self: QueryMixinHost) -> None: + """Execute the visually selected text.""" + self.action_execute_query() + + self._visual_anchor = 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 9444ab36..e416394e 100644 --- a/sqlit/domains/shell/app/main.css +++ b/sqlit/domains/shell/app/main.css @@ -136,8 +136,9 @@ color: $surface; } + #query-area.vim-visual TextArea > .text-area--cursor, #query-area.vim-visual-line TextArea > .text-area--cursor { - /* Block cursor for VISUAL LINE mode */ + /* Block cursor for VISUAL modes */ background: $mode-normal-color; color: $surface; } diff --git a/sqlit/domains/shell/state/machine.py b/sqlit/domains/shell/state/machine.py index 532af46e..320528f4 100644 --- a/sqlit/domains/shell/state/machine.py +++ b/sqlit/domains/shell/state/machine.py @@ -36,6 +36,7 @@ QueryFocusedState, QueryInsertModeState, QueryNormalModeState, + QueryVisualModeState, QueryVisualLineModeState, ) from sqlit.domains.results.state import ( @@ -74,6 +75,7 @@ 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) @@ -98,6 +100,7 @@ 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, @@ -217,8 +220,19 @@ 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("", "Exit visual mode")) + 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")) diff --git a/sqlit/domains/shell/ui/mixins/ui_status.py b/sqlit/domains/shell/ui/mixins/ui_status.py index 0578a5c3..c0d7214f 100644 --- a/sqlit/domains/shell/ui/mixins/ui_status.py +++ b/sqlit/domains/shell/ui/mixins/ui_status.py @@ -133,10 +133,12 @@ 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", "vim-visual-line") + 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: @@ -219,6 +221,9 @@ def _update_status_bar(self: UINavigationMixinHost) -> None: if self.vim_mode == VimMode.NORMAL: 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 " @@ -463,7 +468,7 @@ def _update_footer_bindings(self: UINavigationMixinHost) -> None: if not ctx.modal_open and ctx.focus == "query": if ctx.vim_mode == VimMode.INSERT: key_color = insert_color - elif ctx.vim_mode == VimMode.VISUAL_LINE: + elif ctx.vim_mode in (VimMode.VISUAL, VimMode.VISUAL_LINE): key_color = normal_color footer.set_key_color(key_color) diff --git a/sqlit/shared/ui/widgets_text_area.py b/sqlit/shared/ui/widgets_text_area.py index 8cf53f9e..2e9e1b28 100644 --- a/sqlit/shared/ui/widgets_text_area.py +++ b/sqlit/shared/ui/widgets_text_area.py @@ -200,28 +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_line_mode(self) -> bool: - """Check if app is in vim VISUAL LINE mode.""" + 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 == VimMode.VISUAL_LINE + 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 line mode.""" - if self._is_visual_line_mode(): + """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 line mode.""" - if self._is_visual_line_mode(): + """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 From 7b26576bed0fd373133d0452f48b5c7a4200263e Mon Sep 17 00:00:00 2001 From: Charles Lariviere Date: Wed, 1 Apr 2026 21:30:11 -0400 Subject: [PATCH 3/8] Rename _update_visual_selection to avoid collision with ConnectionMixin The tree explorer's ConnectionMixin already defines _update_visual_selection for tree visual mode. Rename the query editor's method to _update_query_visual_selection to avoid the name conflict. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../domains/query/ui/mixins/query_editing_cursor.py | 12 ++++++------ .../domains/query/ui/mixins/query_editing_visual.py | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/sqlit/domains/query/ui/mixins/query_editing_cursor.py b/sqlit/domains/query/ui/mixins/query_editing_cursor.py index b00ff738..ce5d685c 100644 --- a/sqlit/domains/query/ui/mixins/query_editing_cursor.py +++ b/sqlit/domains/query/ui/mixins/query_editing_cursor.py @@ -37,8 +37,8 @@ def _move_with_motion(self: QueryMixinHost, motion_key: str, char: str | None = 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_visual_selection"): - self._update_visual_selection(cursor=(row, col)) + 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) @@ -62,8 +62,8 @@ def action_g_first_line(self: QueryMixinHost) -> None: 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_visual_selection"): - self._update_visual_selection(cursor=(target_row, 0)) + 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) @@ -149,8 +149,8 @@ def action_cursor_last_line(self: QueryMixinHost) -> None: target_row = max(0, target_row) 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_visual_selection"): - self._update_visual_selection(cursor=(target_row, 0)) + 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: diff --git a/sqlit/domains/query/ui/mixins/query_editing_visual.py b/sqlit/domains/query/ui/mixins/query_editing_visual.py index acb3c67a..a5115b07 100644 --- a/sqlit/domains/query/ui/mixins/query_editing_visual.py +++ b/sqlit/domains/query/ui/mixins/query_editing_visual.py @@ -57,11 +57,11 @@ def action_switch_to_visual_mode(self: QueryMixinHost) -> None: self._visual_line_anchor_row = None self._visual_anchor = anchor self.vim_mode = VimMode.VISUAL - self._update_visual_selection(cursor=cursor) + self._update_query_visual_selection(cursor=cursor) self._update_vim_mode_visuals() self._update_footer_bindings() - def _update_visual_selection( + def _update_query_visual_selection( self: QueryMixinHost, cursor: tuple[int, int] | None = None ) -> None: """Update selection from anchor to cursor position.""" From b5a7c268a6aab07790db40799757a71a51e51883 Mon Sep 17 00:00:00 2001 From: Charles Lariviere Date: Wed, 1 Apr 2026 21:41:22 -0400 Subject: [PATCH 4/8] Add tests for vim visual modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unit tests (22): binding context resolution, state machine action validation, key routing, and footer bindings for both VISUAL and VISUAL LINE modes. UI integration tests (19): enter/exit, mode toggling (v↔V), motion-based selection extension, and operators (yank, delete, change) using Textual pilot. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/ui/keybindings/test_visual_mode.py | 452 +++++++++++++++++++++++ tests/unit/test_vim_visual_mode.py | 238 ++++++++++++ 2 files changed, 690 insertions(+) create mode 100644 tests/ui/keybindings/test_visual_mode.py create mode 100644 tests/unit/test_vim_visual_mode.py diff --git a/tests/ui/keybindings/test_visual_mode.py b/tests/ui/keybindings/test_visual_mode.py new file mode 100644 index 00000000..fe421c9f --- /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" by moving to the space + 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 " + # 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 == "world" + + @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 == "world" + + @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 From c6e2188bcc82b7f135e466ba5f35aee398d4e1c2 Mon Sep 17 00:00:00 2001 From: Charles Lariviere Date: Wed, 1 Apr 2026 21:54:10 -0400 Subject: [PATCH 5/8] Fix review findings: timer race, stale anchor, notification color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove duplicate 0.15s clear timers in visual_yank and visual_line_yank — _flash_yank_range already schedules its own. Two competing callbacks could clobber a new selection if the user re-enters visual mode within 150ms. - Clear _visual_anchor before delegating to _change_selection() in action_visual_change, so the anchor can't leak if the delegate raises. - Fix notification color for VISUAL/VISUAL_LINE modes: use insert_color only for INSERT, not as the fallback for all non-NORMAL modes. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../query/ui/mixins/query_editing_visual.py | 15 ++++++--------- .../query/ui/mixins/query_editing_visual_line.py | 16 +++++----------- sqlit/domains/shell/ui/mixins/ui_status.py | 2 +- 3 files changed, 12 insertions(+), 21 deletions(-) diff --git a/sqlit/domains/query/ui/mixins/query_editing_visual.py b/sqlit/domains/query/ui/mixins/query_editing_visual.py index a5115b07..144daf36 100644 --- a/sqlit/domains/query/ui/mixins/query_editing_visual.py +++ b/sqlit/domains/query/ui/mixins/query_editing_visual.py @@ -87,20 +87,15 @@ def action_visual_yank(self: QueryMixinHost) -> None: if text: self._copy_text(text) - self._flash_yank_range(start[0], start[1], end[0], end[1]) - from sqlit.core.vim import VimMode - from textual.widgets.text_area import Selection self._visual_anchor = None self.vim_mode = VimMode.NORMAL self.query_input.cursor_location = (start[0], start[1]) - def _clear() -> None: - cur = self.query_input.cursor_location - self.query_input.selection = Selection(cur, cur) - - self.set_timer(0.15, _clear) + # _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() @@ -119,9 +114,11 @@ def action_visual_delete(self: QueryMixinHost) -> None: def action_visual_change(self: QueryMixinHost) -> None: """Change (delete + insert) the charwise selection.""" + self._visual_anchor = None self._push_undo_state() + # _change_selection calls _enter_insert_mode which handles + # vim_mode, visuals, and footer updates. self._change_selection() - self._visual_anchor = None def action_visual_execute(self: QueryMixinHost) -> None: """Execute the visually selected text.""" diff --git a/sqlit/domains/query/ui/mixins/query_editing_visual_line.py b/sqlit/domains/query/ui/mixins/query_editing_visual_line.py index 5ba092fb..edaa7848 100644 --- a/sqlit/domains/query/ui/mixins/query_editing_visual_line.py +++ b/sqlit/domains/query/ui/mixins/query_editing_visual_line.py @@ -98,24 +98,18 @@ def action_visual_line_yank(self: QueryMixinHost) -> None: if result.yanked: self._copy_text(result.yanked) - # Flash the selection then exit - end_col = len(lines[end_row]) if end_row < len(lines) else 0 - self._flash_yank_range(start_row, 0, end_row, end_col) - - # Exit visual line mode (after flash timer starts) + # Exit visual line mode before flash self._visual_line_anchor_row = None from sqlit.core.vim import VimMode - from textual.widgets.text_area import Selection self.vim_mode = VimMode.NORMAL self.query_input.cursor_location = (start_row, 0) - def _clear_and_update() -> None: - cursor = self.query_input.cursor_location - self.query_input.selection = Selection(cursor, cursor) - - self.set_timer(0.15, _clear_and_update) + # _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() diff --git a/sqlit/domains/shell/ui/mixins/ui_status.py b/sqlit/domains/shell/ui/mixins/ui_status.py index c0d7214f..d3e33a0f 100644 --- a/sqlit/domains/shell/ui/mixins/ui_status.py +++ b/sqlit/domains/shell/ui/mixins/ui_status.py @@ -306,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 From 9b843e54cd68888be2cc8e8df5912aab2188badf Mon Sep 17 00:00:00 2001 From: Charles Lariviere Date: Wed, 1 Apr 2026 21:59:05 -0400 Subject: [PATCH 6/8] Make visual mode selection inclusive of cursor character Vim visual mode includes both the anchor and cursor characters in the selection. Textual's Selection is half-open (end exclusive), so extend the far end by one character to match vim behavior. Also highlight the character under the cursor immediately on entering visual mode. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../query/ui/mixins/query_editing_visual.py | 31 +++++++++++++++++-- tests/ui/keybindings/test_visual_mode.py | 8 ++--- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/sqlit/domains/query/ui/mixins/query_editing_visual.py b/sqlit/domains/query/ui/mixins/query_editing_visual.py index 144daf36..256fd63f 100644 --- a/sqlit/domains/query/ui/mixins/query_editing_visual.py +++ b/sqlit/domains/query/ui/mixins/query_editing_visual.py @@ -13,9 +13,18 @@ class QueryEditingVisualMixin: def action_enter_visual_mode(self: QueryMixinHost) -> None: """Enter charwise visual mode (v).""" from sqlit.core.vim import VimMode + from textual.widgets.text_area import Selection - self._visual_anchor = self.query_input.cursor_location + cursor = self.query_input.cursor_location + self._visual_anchor = cursor self.vim_mode = VimMode.VISUAL + + # Highlight the character under the cursor immediately + row, col = cursor + lines = self.query_input.text.split("\n") + end_col = min(col + 1, len(lines[row]) if row < len(lines) else 0) + self.query_input.selection = Selection(cursor, (row, end_col)) + self._update_vim_mode_visuals() self._update_footer_bindings() @@ -64,7 +73,12 @@ def action_switch_to_visual_mode(self: QueryMixinHost) -> None: def _update_query_visual_selection( self: QueryMixinHost, cursor: tuple[int, int] | None = None ) -> None: - """Update selection from anchor to cursor position.""" + """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. + """ from textual.widgets.text_area import Selection anchor = self._visual_anchor @@ -74,7 +88,18 @@ def _update_query_visual_selection( if cursor is None: cursor = self.query_input.cursor_location - self.query_input.selection = Selection(anchor, 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.""" diff --git a/tests/ui/keybindings/test_visual_mode.py b/tests/ui/keybindings/test_visual_mode.py index fe421c9f..335fcf95 100644 --- a/tests/ui/keybindings/test_visual_mode.py +++ b/tests/ui/keybindings/test_visual_mode.py @@ -302,7 +302,7 @@ async def test_visual_yank(self) -> None: await pilot.press("v") await pilot.pause() - # Select "hello" by moving to the space + # Select "hello w" — w moves to col 6, inclusive of cursor char await pilot.press("w") await pilot.pause() @@ -310,7 +310,7 @@ async def test_visual_yank(self) -> None: await pilot.pause() assert app.vim_mode == VimMode.NORMAL - assert app._internal_clipboard == "hello " + assert app._internal_clipboard == "hello w" # Text should be unchanged assert app.query_input.text == "hello world" @@ -336,7 +336,7 @@ async def test_visual_delete(self) -> None: await pilot.pause() assert app.vim_mode == VimMode.NORMAL - assert app.query_input.text == "world" + assert app.query_input.text == "orld" @pytest.mark.asyncio async def test_visual_change_enters_insert(self) -> None: @@ -360,7 +360,7 @@ async def test_visual_change_enters_insert(self) -> None: await pilot.pause() assert app.vim_mode == VimMode.INSERT - assert app.query_input.text == "world" + assert app.query_input.text == "orld" @pytest.mark.asyncio async def test_visual_line_yank(self) -> None: From 7988f46bc8f9e4b354cecdad3cdce72a6fe11f0f Mon Sep 17 00:00:00 2001 From: Charles Lariviere Date: Wed, 1 Apr 2026 22:00:37 -0400 Subject: [PATCH 7/8] Add x as alias for d (delete/cut) in visual modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In vim, x in visual mode is equivalent to d — both delete the selection. Add x as a secondary binding for visual_delete and visual_line_delete. Co-Authored-By: Claude Opus 4.6 (1M context) --- sqlit/core/keymap.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sqlit/core/keymap.py b/sqlit/core/keymap.py index 5f6c08f1..64aab69a 100644 --- a/sqlit/core/keymap.py +++ b/sqlit/core/keymap.py @@ -377,6 +377,7 @@ def _build_action_keys(self) -> list[ActionKeyDef]: 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"), @@ -407,6 +408,7 @@ def _build_action_keys(self) -> list[ActionKeyDef]: 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"), From d1fb835577c7eef90e0d332d382a867aa4bfe472 Mon Sep 17 00:00:00 2001 From: Charles Lariviere Date: Wed, 1 Apr 2026 22:06:05 -0400 Subject: [PATCH 8/8] Fix visual mode left movement stuck due to inclusive selection offset The inclusive selection extends the far end by +1, but cursor_location returns that extended position. Motions reading cursor_location would operate from the wrong position, making h/left appear stuck when the cursor was on the same line as the anchor. Track the logical cursor position in _visual_cursor separately from the TextArea selection endpoint. Motion functions now read from _visual_cursor in charwise visual mode instead of cursor_location. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../query/ui/mixins/query_editing_cursor.py | 9 +++++++- .../query/ui/mixins/query_editing_visual.py | 23 +++++++++++-------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/sqlit/domains/query/ui/mixins/query_editing_cursor.py b/sqlit/domains/query/ui/mixins/query_editing_cursor.py index ce5d685c..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): diff --git a/sqlit/domains/query/ui/mixins/query_editing_visual.py b/sqlit/domains/query/ui/mixins/query_editing_visual.py index 256fd63f..a64aa80d 100644 --- a/sqlit/domains/query/ui/mixins/query_editing_visual.py +++ b/sqlit/domains/query/ui/mixins/query_editing_visual.py @@ -9,22 +9,17 @@ 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 - from textual.widgets.text_area import Selection cursor = self.query_input.cursor_location self._visual_anchor = cursor + self._visual_cursor = cursor self.vim_mode = VimMode.VISUAL - - # Highlight the character under the cursor immediately - row, col = cursor - lines = self.query_input.text.split("\n") - end_col = min(col + 1, len(lines[row]) if row < len(lines) else 0) - self.query_input.selection = Selection(cursor, (row, end_col)) - + self._update_query_visual_selection(cursor=cursor) self._update_vim_mode_visuals() self._update_footer_bindings() @@ -34,6 +29,7 @@ def action_exit_visual_mode(self: QueryMixinHost) -> None: 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) @@ -49,6 +45,7 @@ def action_switch_to_visual_line_mode(self: QueryMixinHost) -> None: 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) @@ -78,6 +75,9 @@ def _update_query_visual_selection( 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 @@ -86,8 +86,9 @@ def _update_query_visual_selection( return if cursor is None: - cursor = self.query_input.cursor_location + cursor = self._visual_cursor or self.query_input.cursor_location + self._visual_cursor = cursor lines = self.query_input.text.split("\n") if cursor >= anchor: @@ -115,6 +116,7 @@ def action_visual_yank(self: QueryMixinHost) -> None: 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]) @@ -130,6 +132,7 @@ def action_visual_delete(self: QueryMixinHost) -> None: self._delete_selection() self._visual_anchor = None + self._visual_cursor = None from sqlit.core.vim import VimMode @@ -140,6 +143,7 @@ def action_visual_delete(self: QueryMixinHost) -> None: 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. @@ -150,6 +154,7 @@ def action_visual_execute(self: QueryMixinHost) -> None: 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