diff --git a/sqlit/domains/shell/state/help_doc.py b/sqlit/domains/shell/state/help_doc.py new file mode 100644 index 00000000..93c112d4 --- /dev/null +++ b/sqlit/domains/shell/state/help_doc.py @@ -0,0 +1,96 @@ +"""Structured help document model. + +Help content is generated as an ordered list of `HelpSection`s. Each section +has a stable `id` so the UI can scroll/reorder by current context, and each +binding item carries its raw key + description so the view can filter by +substring without re-parsing rendered markup. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal + + +@dataclass +class HelpItem: + """A single line inside a help section.""" + + kind: Literal["binding", "subsection"] + key: str = "" + description: str = "" + title: str = "" + + def matches(self, query: str) -> bool: + """Substring match against key + description (case-insensitive).""" + if not query: + return True + q = query.lower() + if self.kind == "subsection": + return q in self.title.lower() + return q in self.key.lower() or q in self.description.lower() + + +@dataclass +class HelpSection: + """A titled group of help items.""" + + id: str + title: str + items: list[HelpItem] = field(default_factory=list) + + def binding(self, key: str, description: str) -> HelpSection: + self.items.append(HelpItem(kind="binding", key=key, description=description)) + return self + + def subsection(self, title: str) -> HelpSection: + self.items.append(HelpItem(kind="subsection", title=title)) + return self + + +def render_section(section: HelpSection, query: str = "") -> tuple[str, int]: + """Render a section to Rich markup. Returns (markup, matched_item_count). + + When `query` is non-empty, items not matching are skipped. A subsection + header is kept only if at least one following binding (up to the next + subsection) matches. + """ + divider = "-" * 62 + lines: list[str] = [f"[bold $primary]{section.title}[/]", f"[dim]{divider}[/]"] + matched = 0 + + items = section.items + if query: + kept: list[HelpItem] = [] + i = 0 + while i < len(items): + item = items[i] + if item.kind == "subsection": + j = i + 1 + group_match = False + group: list[HelpItem] = [] + while j < len(items) and items[j].kind != "subsection": + if items[j].matches(query): + group_match = True + group.append(items[j]) + j += 1 + if group_match: + kept.append(item) + kept.extend(group) + i = j + else: + if item.matches(query): + kept.append(item) + i += 1 + items = kept + + for item in items: + if item.kind == "subsection": + lines.append(f" [bold $text-muted]{item.title}[/]") + else: + lines.append( + f" [bold $warning]{item.key:<14}[/] [dim]-[/] {item.description}" + ) + matched += 1 + + return "\n".join(lines), matched diff --git a/sqlit/domains/shell/state/machine.py b/sqlit/domains/shell/state/machine.py index 65d282a0..dc146c35 100644 --- a/sqlit/domains/shell/state/machine.py +++ b/sqlit/domains/shell/state/machine.py @@ -44,12 +44,38 @@ ValueViewSyntaxModeState, ValueViewTreeModeState, ) +from sqlit.domains.shell.state.help_doc import HelpSection from sqlit.domains.shell.state.leader_pending import LeaderPendingState from sqlit.domains.shell.state.main_screen import MainScreenState from sqlit.domains.shell.state.modal_active import ModalActiveState from sqlit.domains.shell.state.root import RootState +STATE_TO_HELP_SECTION: dict[str, str] = { + "QueryInsertModeState": "query_insert", + "AutocompleteActiveState": "query_insert", + "QueryNormalModeState": "query_normal", + "QueryFocusedState": "query_normal", + "QueryVisualModeState": "query_visual", + "QueryVisualLineModeState": "query_visual_line", + "TreeFilterActiveState": "filtering", + "TreeOnConnectionState": "explorer_connection", + "TreeOnDatabaseState": "explorer", + "TreeOnTableState": "explorer", + "TreeOnFolderState": "explorer", + "TreeOnObjectState": "explorer", + "TreeVisualModeState": "explorer", + "TreeMultiSelectState": "explorer", + "TreeFocusedState": "explorer", + "ResultsFilterActiveState": "filtering", + "ResultsFocusedState": "results", + "ValueViewActiveState": "results", + "ValueViewTreeModeState": "results", + "ValueViewSyntaxModeState": "results", + "LeaderPendingState": "command_menu", +} + + class UIStateMachine: """Hierarchical state machine for UI action validation and binding display.""" @@ -135,8 +161,13 @@ def get_active_state_name(self, app: InputContext) -> str: state = self.get_active_state(app) return state.__class__.__name__ - def generate_help_text(self) -> str: - """Generate structured help text with organized sections. + def get_active_help_section_id(self, app: InputContext) -> str | None: + """Return the help section id that matches the active state, if any.""" + state = self.get_active_state(app) + return STATE_TO_HELP_SECTION.get(state.__class__.__name__) + + def generate_help_sections(self) -> list[HelpSection]: + """Generate structured help sections. Keys are resolved from the active keymap so custom keybindings show up here too. Literal fallbacks are kept for sequences that aren't bound to @@ -158,216 +189,198 @@ def lk(action: str, menu: str, fallback: str) -> str: key = keymap.leader(action, menu) return format_key(key) if key else fallback - def section(title: str) -> str: - divider = "-" * 62 - return f"[bold $primary]{title}[/]\n[dim]{divider}[/]" + sections: list[HelpSection] = [] - def subsection(title: str) -> str: - return f" [bold $text-muted]{title}[/]" - - def binding(key: str, desc: str, indent: int = 4) -> str: - pad = " " * indent - return f"{pad}[bold $warning]{key:<14}[/] [dim]-[/] {desc}" - - lines: list[str] = [] - - # ═══════════════════════════════════════════════════════════════════ # GLOBAL - # ═══════════════════════════════════════════════════════════════════ - lines.append(section("GLOBAL")) - lines.append(binding(":q", "Quit")) - lines.append(binding(f"{leader_key}{lk('change_theme', 'leader', 't')}", "Change theme")) - lines.append(binding(f"{leader_key}{lk('toggle_fullscreen', 'leader', 'f')}", "Toggle fullscreen pane")) - lines.append(binding(f"{leader_key}{lk('toggle_explorer', 'leader', 'e')}", "Toggle explorer visibility")) - lines.append("") - - # ═══════════════════════════════════════════════════════════════════ + s = HelpSection(id="global", title="GLOBAL") + s.binding(":q", "Quit") + s.binding(f"{leader_key}{lk('change_theme', 'leader', 't')}", "Change theme") + s.binding(f"{leader_key}{lk('toggle_fullscreen', 'leader', 'f')}", "Toggle fullscreen pane") + s.binding(f"{leader_key}{lk('toggle_explorer', 'leader', 'e')}", "Toggle explorer visibility") + sections.append(s) + # NAVIGATION - # ═══════════════════════════════════════════════════════════════════ - lines.append(section("NAVIGATION")) - lines.append(binding(k("focus_explorer", "e"), "Focus Explorer pane")) - lines.append(binding(k("focus_query", "q"), "Focus Query pane")) - lines.append(binding(k("focus_results", "r"), "Focus Results pane")) - lines.append(binding(leader_key, "Open command menu")) - lines.append(binding(k("show_help", "?"), "Show this help")) - lines.append("") - - # ═══════════════════════════════════════════════════════════════════ + s = HelpSection(id="navigation", title="NAVIGATION") + s.binding(k("focus_explorer", "e"), "Focus Explorer pane") + s.binding(k("focus_query", "q"), "Focus Query pane") + s.binding(k("focus_results", "r"), "Focus Results pane") + s.binding(leader_key, "Open command menu") + s.binding(k("show_help", "?"), "Show this help") + sections.append(s) + # EXPLORER - # ═══════════════════════════════════════════════════════════════════ - lines.append(section("EXPLORER")) - lines.append(binding(ks([("tree_cursor_down", "j"), ("tree_cursor_up", "k")]), "Move cursor down/up")) - lines.append(binding("", "Expand node / Connect")) - lines.append(binding(k("new_connection", "n"), "New connection")) - lines.append(binding(k("select_table", "s"), "SELECT TOP 100 (on table/view)")) - lines.append(binding(k("tree_filter", "/"), "Filter tree")) - lines.append(binding(k("collapse_tree", "z"), "Collapse all nodes")) - lines.append(binding(k("refresh_tree", "f"), "Refresh tree")) - lines.append("") - lines.append(subsection("On Connection Node:")) - lines.append(binding(k("edit_connection", "e"), "Edit connection")) - lines.append(binding(k("delete_connection", "d"), "Delete connection")) - lines.append(binding(k("duplicate_connection", "D"), "Duplicate connection")) - lines.append(binding(k("disconnect", "x"), "Disconnect")) - lines.append("") - - # ═══════════════════════════════════════════════════════════════════ - # QUERY EDITOR - # ═══════════════════════════════════════════════════════════════════ + s = HelpSection(id="explorer", title="EXPLORER") + s.binding(ks([("tree_cursor_down", "j"), ("tree_cursor_up", "k")]), "Move cursor down/up") + s.binding("", "Expand node / Connect") + s.binding(k("new_connection", "n"), "New connection") + s.binding(k("select_table", "s"), "SELECT TOP 100 (on table/view)") + s.binding(k("tree_filter", "/"), "Filter tree") + s.binding(k("collapse_tree", "z"), "Collapse all nodes") + s.binding(k("refresh_tree", "f"), "Refresh tree") + sections.append(s) + + # EXPLORER · ON CONNECTION NODE + s = HelpSection(id="explorer_connection", title="EXPLORER · ON CONNECTION NODE") + s.binding(k("edit_connection", "e"), "Edit connection") + s.binding(k("delete_connection", "d"), "Delete connection") + s.binding(k("duplicate_connection", "D"), "Duplicate connection") + s.binding(k("disconnect", "x"), "Disconnect") + sections.append(s) + + # QUERY EDITOR · NORMAL MODE g_key = k("g_leader_key", "g") - lines.append(section("QUERY EDITOR")) - lines.append(subsection("Normal Mode:")) - lines.append(binding(ks([("enter_insert_mode", "i"), ("prepend_insert_mode", "I")]), "Enter INSERT mode")) - lines.append(binding(ks([("open_line_below", "o"), ("open_line_above", "O")]), "Open line below/above")) - lines.append(binding(k("change_line_end_motion", "C"), "Change to line end")) - lines.append(binding(k("delete_line_end", "D"), "Delete to line end")) - lines.append(binding(f"{k('execute_query', '')}/{g_key}{lk('execute_query', 'g', 'r')}", "Execute query")) - lines.append(binding(f"{g_key}{lk('execute_query_atomic', 'g', 't')}", "Execute as transaction")) - lines.append(binding(k("show_history", ""), "Query history")) - lines.append(binding(k("new_query", "N"), "New query (clear)")) - lines.append(binding(k("undo", "u"), "Undo")) - lines.append(binding(k("redo", "^r"), "Redo")) - lines.append("") - lines.append(subsection("Insert Mode:")) - lines.append(binding(k("exit_insert_mode", ""), "Exit to NORMAL mode")) - lines.append(binding(k("execute_query_insert", "^enter"), "Execute (stay in INSERT)")) - lines.append(binding(k("autocomplete_accept", ""), "Accept autocomplete")) - lines.append(binding(k("select_all", "^a"), "Select all")) - lines.append(binding(k("copy_selection", "^c"), "Copy selection")) - lines.append(binding(k("paste", "^v"), "Paste")) - lines.append("") - lines.append(subsection(f"Visual Mode ({k('enter_visual_mode', 'v')}):")) - lines.append(binding(f"{k('exit_visual_mode', '')}/{k('enter_visual_mode', 'v')}", "Exit visual mode")) - lines.append(binding(k("switch_to_visual_line_mode", "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(k("visual_yank", "y"), "Yank selection")) - lines.append(binding(k("visual_delete", "d"), "Delete selection")) - lines.append(binding(k("visual_change", "c"), "Change selection")) - lines.append(binding(k("visual_execute", ""), "Execute selection")) - lines.append("") - lines.append(subsection(f"Visual Line Mode ({k('enter_visual_line_mode', 'V')}):")) - lines.append(binding(f"{k('exit_visual_line_mode', '')}/{k('enter_visual_line_mode', 'V')}", "Exit visual line mode")) - lines.append(binding(k("switch_to_visual_mode", "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(k("visual_line_yank", "y"), "Yank selected lines")) - lines.append(binding(k("visual_line_delete", "d"), "Delete selected lines")) - lines.append(binding(k("visual_line_change", "c"), "Change selected lines")) - lines.append(binding(k("visual_line_execute", ""), "Execute selected lines")) - lines.append("") - # Operator + motion sequences: resolve the operator key, keep "{motion}" literal. + s = HelpSection(id="query_normal", title="QUERY EDITOR · NORMAL MODE") + s.binding(ks([("enter_insert_mode", "i"), ("prepend_insert_mode", "I")]), "Enter INSERT mode") + s.binding(ks([("open_line_below", "o"), ("open_line_above", "O")]), "Open line below/above") + s.binding(k("change_line_end_motion", "C"), "Change to line end") + s.binding(k("delete_line_end", "D"), "Delete to line end") + s.binding(f"{k('execute_query', '')}/{g_key}{lk('execute_query', 'g', 'r')}", "Execute query") + s.binding(f"{g_key}{lk('execute_query_atomic', 'g', 't')}", "Execute as transaction") + s.binding(k("show_history", ""), "Query history") + s.binding(k("new_query", "N"), "New query (clear)") + s.binding(k("undo", "u"), "Undo") + s.binding(k("redo", "^r"), "Redo") + sections.append(s) + + # QUERY EDITOR · INSERT MODE + s = HelpSection(id="query_insert", title="QUERY EDITOR · INSERT MODE") + s.binding(k("exit_insert_mode", ""), "Exit to NORMAL mode") + s.binding(k("execute_query_insert", "^enter"), "Execute (stay in INSERT)") + s.binding(k("autocomplete_accept", ""), "Accept autocomplete") + s.binding(k("select_all", "^a"), "Select all") + s.binding(k("copy_selection", "^c"), "Copy selection") + s.binding(k("paste", "^v"), "Paste") + sections.append(s) + + # QUERY EDITOR · VISUAL MODE + s = HelpSection( + id="query_visual", + title=f"QUERY EDITOR · VISUAL MODE ({k('enter_visual_mode', 'v')})", + ) + s.binding(f"{k('exit_visual_mode', '')}/{k('enter_visual_mode', 'v')}", "Exit visual mode") + s.binding(k("switch_to_visual_line_mode", "V"), "Switch to visual line mode") + s.binding("h/j/k/l", "Extend selection") + s.binding("w/b/e/$", "Extend by word/line motions") + s.binding(k("visual_yank", "y"), "Yank selection") + s.binding(k("visual_delete", "d"), "Delete selection") + s.binding(k("visual_change", "c"), "Change selection") + s.binding(k("visual_execute", ""), "Execute selection") + sections.append(s) + + # QUERY EDITOR · VISUAL LINE MODE + s = HelpSection( + id="query_visual_line", + title=f"QUERY EDITOR · VISUAL LINE MODE ({k('enter_visual_line_mode', 'V')})", + ) + s.binding(f"{k('exit_visual_line_mode', '')}/{k('enter_visual_line_mode', 'V')}", "Exit visual line mode") + s.binding(k("switch_to_visual_mode", "v"), "Switch to visual mode") + s.binding("j/k", "Extend selection down/up") + s.binding("gg/G", "Extend to first/last line") + s.binding(k("visual_line_yank", "y"), "Yank selected lines") + s.binding(k("visual_line_delete", "d"), "Delete selected lines") + s.binding(k("visual_line_change", "c"), "Change selected lines") + s.binding(k("visual_line_execute", ""), "Execute selected lines") + sections.append(s) + + # VIM OPERATORS + MOTIONS + TEXT OBJECTS yank_op = k("yank_leader_key", "y") del_op = k("delete_leader_key", "d") chg_op = k("change_leader_key", "c") - lines.append(subsection("Vim Operators (Normal Mode):")) - lines.append(binding(f"{yank_op}{{motion}}", "Copy")) - lines.append(binding(f"{del_op}{{motion}}", "Delete")) - lines.append(binding(f"{chg_op}{{motion}}", "Change (delete + INSERT)")) - lines.append(binding(k("paste", "p"), "Paste after cursor")) - lines.append("") - lines.append(subsection("Vim Motions:")) - lines.append(binding(ks([("cursor_left", "h"), ("cursor_down", "j"), ("cursor_up", "k"), ("cursor_right", "l")]), "Cursor left/down/up/right")) - lines.append(binding(ks([("cursor_word_forward", "w"), ("cursor_WORD_forward", "W")]), "Word forward")) - lines.append(binding(ks([("cursor_word_back", "b"), ("cursor_WORD_back", "B")]), "Word backward")) - # `^` (first non-blank) is not a separate keymap action — kept as a literal. - lines.append(binding(f"{k('cursor_line_start', '0')}/^/{k('cursor_line_end', '$')}", "Line start/first char/end")) - lines.append(binding(f"{g_key}{lk('first_line', 'g', 'g')}/{k('cursor_last_line', 'G')}", "File start/end")) - lines.append(binding(f"{k('cursor_find_char', 'f')}{{c}}/{k('cursor_find_char_back', 'F')}{{c}}", "Find char forward/back")) - lines.append(binding(f"{k('cursor_till_char', 't')}{{c}}/{k('cursor_till_char_back', 'T')}{{c}}", "Till char forward/back")) - lines.append(binding(k("cursor_matching_bracket", "%"), "Matching bracket")) - lines.append("") - # Text objects: inner/around are leader_commands inside operator menus (yank/delete/change). - # Resolve from the yank menu — by convention these stay aligned across menus. + s = HelpSection(id="vim_operators", title="VIM OPERATORS (NORMAL MODE)") + s.binding(f"{yank_op}{{motion}}", "Copy") + s.binding(f"{del_op}{{motion}}", "Delete") + s.binding(f"{chg_op}{{motion}}", "Change (delete + INSERT)") + s.binding(k("paste", "p"), "Paste after cursor") + sections.append(s) + + s = HelpSection(id="vim_motions", title="VIM MOTIONS") + s.binding(ks([("cursor_left", "h"), ("cursor_down", "j"), ("cursor_up", "k"), ("cursor_right", "l")]), "Cursor left/down/up/right") + s.binding(ks([("cursor_word_forward", "w"), ("cursor_WORD_forward", "W")]), "Word forward") + s.binding(ks([("cursor_word_back", "b"), ("cursor_WORD_back", "B")]), "Word backward") + s.binding(f"{k('cursor_line_start', '0')}/^/{k('cursor_line_end', '$')}", "Line start/first char/end") + s.binding(f"{g_key}{lk('first_line', 'g', 'g')}/{k('cursor_last_line', 'G')}", "File start/end") + s.binding(f"{k('cursor_find_char', 'f')}{{c}}/{k('cursor_find_char_back', 'F')}{{c}}", "Find char forward/back") + s.binding(f"{k('cursor_till_char', 't')}{{c}}/{k('cursor_till_char_back', 'T')}{{c}}", "Till char forward/back") + s.binding(k("cursor_matching_bracket", "%"), "Matching bracket") + sections.append(s) + inner = lk("inner", "yank", "i") around = lk("around", "yank", "a") - lines.append(subsection(f"Text Objects (with {inner}=inner, {around}=around):")) - lines.append(binding(f"{inner}w/{around}w", "Word")) - lines.append(binding(f'{inner}"/{around}"', "Double quotes")) - lines.append(binding(f"{inner}'/{around}'", "Single quotes")) - lines.append(binding(f"{inner})/{around})", "Parentheses")) - lines.append(binding(f"{inner}}}/{around}}}", "Braces")) - lines.append(binding(f"{inner}]/{around}]", "Brackets")) - lines.append("") - - # ═══════════════════════════════════════════════════════════════════ + s = HelpSection( + id="text_objects", + title=f"TEXT OBJECTS (with {inner}=inner, {around}=around)", + ) + s.binding(f"{inner}w/{around}w", "Word") + s.binding(f'{inner}"/{around}"', "Double quotes") + s.binding(f"{inner}'/{around}'", "Single quotes") + s.binding(f"{inner})/{around})", "Parentheses") + s.binding(f"{inner}}}/{around}}}", "Braces") + s.binding(f"{inner}]/{around}]", "Brackets") + sections.append(s) + # RESULTS - # ═══════════════════════════════════════════════════════════════════ - lines.append(section("RESULTS")) - lines.append(binding(ks([("results_cursor_left", "h"), ("results_cursor_down", "j"), ("results_cursor_up", "k"), ("results_cursor_right", "l")]), "Navigate cells")) - lines.append(binding(k("view_cell", "v"), "Preview cell (inline)")) - lines.append(binding(k("view_cell_full", "V"), "View full cell value")) - lines.append(binding(k("edit_cell", "u"), "Generate UPDATE statement")) - lines.append(binding(k("delete_row", "d"), "Generate DELETE statement")) - lines.append(binding(k("results_filter", "/"), "Filter rows")) - lines.append(binding(k("clear_results", "x"), "Clear results")) - lines.append(binding(k("next_result_section", ""), "Next result set")) - lines.append(binding(k("prev_result_section", ""), "Previous result set")) - lines.append(binding(k("toggle_result_section", "z"), "Collapse/expand result")) - lines.append("") + s = HelpSection(id="results", title="RESULTS") + s.binding(ks([("results_cursor_left", "h"), ("results_cursor_down", "j"), ("results_cursor_up", "k"), ("results_cursor_right", "l")]), "Navigate cells") + s.binding(k("view_cell", "v"), "Preview cell (inline)") + s.binding(k("view_cell_full", "V"), "View full cell value") + s.binding(k("edit_cell", "u"), "Generate UPDATE statement") + s.binding(k("delete_row", "d"), "Generate DELETE statement") + s.binding(k("results_filter", "/"), "Filter rows") + s.binding(k("clear_results", "x"), "Clear results") + s.binding(k("next_result_section", ""), "Next result set") + s.binding(k("prev_result_section", ""), "Previous result set") + s.binding(k("toggle_result_section", "z"), "Collapse/expand result") results_yank = k("results_yank_leader_key", "y") - lines.append(subsection(f"Copy Menu ({results_yank}):")) - lines.append(binding(f"{results_yank}{lk('cell', 'ry', 'c')}", "Copy cell")) - lines.append(binding(f"{results_yank}{lk('row', 'ry', 'y')}", "Copy row")) - lines.append(binding(f"{results_yank}{lk('all', 'ry', 'a')}", "Copy all")) - lines.append(binding(f"{results_yank}{lk('export', 'ry', 'e')}", "Export menu...")) - lines.append("") - - # ═══════════════════════════════════════════════════════════════════ + s.subsection(f"Copy Menu ({results_yank}):") + s.binding(f"{results_yank}{lk('cell', 'ry', 'c')}", "Copy cell") + s.binding(f"{results_yank}{lk('row', 'ry', 'y')}", "Copy row") + s.binding(f"{results_yank}{lk('all', 'ry', 'a')}", "Copy all") + s.binding(f"{results_yank}{lk('export', 'ry', 'e')}", "Export menu...") + sections.append(s) + # FILTERING - # ═══════════════════════════════════════════════════════════════════ - lines.append(section("FILTERING")) - lines.append(binding(k("results_filter", "/"), "Open filter (Explorer/Results)")) - lines.append(binding(k("results_filter_accept", ""), "Apply filter")) - lines.append(binding(k("results_filter_close", ""), "Close filter")) - lines.append(binding("~prefix", "Fuzzy match mode")) - lines.append("") - - # ═══════════════════════════════════════════════════════════════════ + s = HelpSection(id="filtering", title="FILTERING") + s.binding(k("results_filter", "/"), "Open filter (Explorer/Results)") + s.binding(k("results_filter_accept", ""), "Apply filter") + s.binding(k("results_filter_close", ""), "Close filter") + s.binding("~prefix", "Fuzzy match mode") + sections.append(s) + # COMMAND MENU - # ═══════════════════════════════════════════════════════════════════ - lines.append(section(f"COMMAND MENU ({leader_key})")) + s = HelpSection(id="command_menu", title=f"COMMAND MENU ({leader_key})") leader_cmds = get_leader_commands("leader") by_cat: dict[str, list[tuple[str, str]]] = {} for cmd in leader_cmds: - if cmd.category not in by_cat: - by_cat[cmd.category] = [] - by_cat[cmd.category].append((cmd.key, cmd.label)) - + by_cat.setdefault(cmd.category, []).append((cmd.key, cmd.label)) for cat in ["View", "Connection", "Actions"]: if cat in by_cat: - lines.append(subsection(f"{cat}:")) + s.subsection(f"{cat}:") for key, label in by_cat[cat]: - lines.append(binding(f"{leader_key}{format_key(key)}", label)) - lines.append("") + s.binding(f"{leader_key}{format_key(key)}", label) + sections.append(s) - # ═══════════════════════════════════════════════════════════════════ # CONNECTION PICKER - # ═══════════════════════════════════════════════════════════════════ - lines.append(section("CONNECTION PICKER")) - lines.append(binding("/", "Search connections")) - lines.append(binding("j/k", "Navigate list")) - lines.append(binding("", "Connect to selected")) - lines.append(binding(k("new_connection", "n"), "New connection")) - lines.append(binding(k("edit_connection", "e"), "Edit connection")) - lines.append(binding(k("delete_connection", "d"), "Delete connection")) - lines.append(binding(k("duplicate_connection", "D"), "Duplicate connection")) - lines.append(binding("", "Close picker")) - lines.append("") - - # ═══════════════════════════════════════════════════════════════════ + s = HelpSection(id="connection_picker", title="CONNECTION PICKER") + s.binding("/", "Search connections") + s.binding("j/k", "Navigate list") + s.binding("", "Connect to selected") + s.binding(k("new_connection", "n"), "New connection") + s.binding(k("edit_connection", "e"), "Edit connection") + s.binding(k("delete_connection", "d"), "Delete connection") + s.binding(k("duplicate_connection", "D"), "Duplicate connection") + s.binding("", "Close picker") + sections.append(s) + # COMMAND MODE - # ═══════════════════════════════════════════════════════════════════ - lines.append(section("COMMAND MODE")) - lines.append(binding(":", "Enter command mode")) - lines.append(binding(":commands", "Show command list")) - lines.append("") + s = HelpSection(id="command_mode", title="COMMAND MODE") + s.binding(":", "Enter command mode") + s.binding(":commands", "Show command list") + sections.append(s) - # ═══════════════════════════════════════════════════════════════════ # SETTINGS - # ═══════════════════════════════════════════════════════════════════ - lines.append(section("SETTINGS")) - lines.append(binding(":alert off|delete|write", "Confirm risky queries")) - lines.append(binding(":set ln on|off|relative", "Line numbers")) + s = HelpSection(id="settings", title="SETTINGS") + s.binding(":alert off|delete|write", "Confirm risky queries") + s.binding(":set ln on|off|relative", "Line numbers") + sections.append(s) - return "\n".join(lines) + return sections diff --git a/sqlit/domains/shell/ui/mixins/ui_navigation.py b/sqlit/domains/shell/ui/mixins/ui_navigation.py index c08e7eb2..e0833228 100644 --- a/sqlit/domains/shell/ui/mixins/ui_navigation.py +++ b/sqlit/domains/shell/ui/mixins/ui_navigation.py @@ -178,8 +178,10 @@ def action_show_help(self: UINavigationMixinHost) -> None: """Show help with all keybindings.""" from ..screens import HelpScreen - help_text = self._state_machine.generate_help_text() - self.push_screen(HelpScreen(help_text)) + sections = self._state_machine.generate_help_sections() + ctx = self._get_input_context() + active_section_id = self._state_machine.get_active_help_section_id(ctx) + self.push_screen(HelpScreen(sections, active_section_id)) def action_show_keybinding_editor(self: UINavigationMixinHost) -> None: """Open the in-app keybinding editor.""" diff --git a/sqlit/domains/shell/ui/screens/help.py b/sqlit/domains/shell/ui/screens/help.py index d6f1fb32..bb1cefb9 100644 --- a/sqlit/domains/shell/ui/screens/help.py +++ b/sqlit/domains/shell/ui/screens/help.py @@ -2,24 +2,34 @@ from __future__ import annotations +from rich.markup import escape as escape_markup from textual.app import ComposeResult from textual.binding import Binding from textual.containers import VerticalScroll +from textual.events import Key from textual.screen import ModalScreen from textual.widgets import Static +from sqlit.domains.shell.state.help_doc import HelpSection, render_section from sqlit.shared.ui.widgets import Dialog class HelpScreen(ModalScreen): - """Modal screen showing keyboard shortcuts and navigation tips.""" + """Modal screen showing keyboard shortcuts and navigation tips. + + The section matching the active UI context (when help was opened) is + rendered first. `/` enters a filter-by-substring mode; Esc backs out of + search, then clears any applied filter, then closes the screen. + """ BINDINGS = [ - Binding("escape,enter,q", "dismiss", "Close"), + Binding("escape", "escape", "Close", show=False), + Binding("enter,q", "dismiss_modal", "Close", show=False), Binding("j", "scroll_down", "Scroll down", show=False), Binding("k", "scroll_up", "Scroll up", show=False), Binding("g", "scroll_home", "Scroll to top", show=False), Binding("G", "scroll_end", "Scroll to bottom", show=False), + Binding("slash", "start_search", "Search", show=False), ] CSS = """ @@ -51,18 +61,101 @@ class HelpScreen(ModalScreen): } """ - def __init__(self, help_text: str): + def __init__( + self, + sections: list[HelpSection], + active_section_id: str | None = None, + ) -> None: super().__init__() - self.help_text = help_text + self._sections = sections + self._active_section_id = active_section_id + self._ordered = self._reorder(sections, active_section_id) + self._search: str = "" + self._searching: bool = False + + @staticmethod + def _reorder( + sections: list[HelpSection], active_id: str | None + ) -> list[HelpSection]: + if not active_id: + return list(sections) + front = [s for s in sections if s.id == active_id] + rest = [s for s in sections if s.id != active_id] + return front + rest def compose(self) -> ComposeResult: with Dialog(id="help-dialog", title="Keyboard Shortcuts"): with VerticalScroll(id="help-scroll"): - yield Static(self.help_text, markup=True) + yield Static(self._build_markup(), id="help-content", markup=True) + + def on_mount(self) -> None: + self._refresh_subtitle() + + def _build_markup(self) -> str: + parts: list[str] = [] + total_matches = 0 + rendered_sections = 0 + for section in self._ordered: + markup, matched = render_section(section, self._search) + if self._search and matched == 0: + continue + parts.append(markup) + total_matches += matched + rendered_sections += 1 + + if self._search and rendered_sections == 0: + safe = escape_markup(self._search) + parts.append(f'[dim]No bindings match "{safe}"[/]') + + if self._searching or self._search: + safe = escape_markup(self._search) if self._search else "" + cursor = "[reverse] [/]" if self._searching else "" + suffix = "es" if total_matches != 1 else "" + header = ( + f"[bold $primary]/[/] {safe}{cursor}" + f" [dim]({total_matches} match{suffix})[/]" + ) + parts.insert(0, header + "\n") + + return "\n\n".join(parts) if parts else "[dim]No help available[/]" + + def _refresh(self) -> None: + self.query_one("#help-content", Static).update(self._build_markup()) + self._refresh_subtitle() + scroll = self.query_one("#help-scroll", VerticalScroll) + scroll.scroll_home(animate=False) + + def _refresh_subtitle(self) -> None: + dialog = self.query_one("#help-dialog", Dialog) + if self._searching: + dialog.border_subtitle = "Type to filter · [bold][/] cancel" + elif self._search: + dialog.border_subtitle = "Filtered · [bold]/[/] edit · [bold][/] clear" + else: + dialog.border_subtitle = "[bold]/[/] search · [bold][/] close" + + def action_dismiss_modal(self) -> None: + if self._searching: + self._searching = False + self._refresh() + return + self.dismiss(None) - def action_dismiss(self) -> None: # type: ignore[override] + def action_escape(self) -> None: + if self._searching: + self._searching = False + self._refresh() + return + if self._search: + self._search = "" + self._refresh() + return self.dismiss(None) + def action_start_search(self) -> None: + self._searching = True + self._refresh() + def action_scroll_down(self) -> None: self.query_one("#help-scroll", VerticalScroll).scroll_down() @@ -74,3 +167,35 @@ def action_scroll_home(self) -> None: def action_scroll_end(self) -> None: self.query_one("#help-scroll", VerticalScroll).scroll_end() + + async def on_key(self, event: Key) -> None: + if not self._searching: + return + + if event.key == "escape": + event.stop() + event.prevent_default() + self._searching = False + self._refresh() + return + + if event.key == "enter": + event.stop() + event.prevent_default() + self._searching = False + self._refresh() + return + + if event.key == "backspace": + event.stop() + event.prevent_default() + if self._search: + self._search = self._search[:-1] + self._refresh() + return + + if event.is_printable and event.character: + event.stop() + event.prevent_default() + self._search += event.character + self._refresh() diff --git a/tests/ui/keybindings/test_dialogs.py b/tests/ui/keybindings/test_dialogs.py index 42d5a4a6..b03b6732 100644 --- a/tests/ui/keybindings/test_dialogs.py +++ b/tests/ui/keybindings/test_dialogs.py @@ -266,6 +266,82 @@ async def test_leader_commands_blocked_when_dialog_open(self): # Screen stack should just have main screen + error dialog assert len(app.screen_stack) == 2 + @pytest.mark.asyncio + async def test_help_dialog_search_filters_bindings(self): + """Pressing `/` in the help dialog should filter bindings by substring.""" + app = _make_app() + + async with app.run_test(size=(100, 35)) as pilot: + keymap = get_keymap() + leader_key = keymap.action("leader_key") + help_key = keymap.leader("show_help") + await pilot.press(leader_key, help_key) + await pilot.pause() + + help_screen = next( + (s for s in app.screen_stack if isinstance(s, HelpScreen)), None + ) + assert help_screen is not None + + await pilot.press("slash") + await pilot.pause() + assert help_screen._searching is True + + for ch in "paste": + await pilot.press(ch) + await pilot.pause() + assert help_screen._search == "paste" + + from sqlit.domains.shell.state.help_doc import render_section + + visible_ids = [ + s.id + for s in help_screen._ordered + if render_section(s, help_screen._search)[1] > 0 + ] + assert visible_ids, "expected at least one section matching 'paste'" + assert "query_insert" in visible_ids + + # Esc exits typing mode but keeps the filter. + await pilot.press("escape") + await pilot.pause() + assert help_screen._searching is False + assert help_screen._search == "paste" + + # Second Esc clears the filter. + await pilot.press("escape") + await pilot.pause() + assert help_screen._search == "" + + # Third Esc dismisses the screen. + await pilot.press("escape") + await pilot.pause() + assert not any(isinstance(s, HelpScreen) for s in app.screen_stack) + + @pytest.mark.asyncio + async def test_help_dialog_opens_with_active_context_first(self): + """Opening help from INSERT mode should put the insert section first.""" + from sqlit.core.vim import VimMode + + app = _make_app() + + async with app.run_test(size=(100, 35)) as pilot: + app.action_focus_query() + await pilot.pause() + app.vim_mode = VimMode.INSERT + app.query_input.read_only = False + await pilot.pause() + + app.action_show_help() + await pilot.pause() + + help_screen = next( + (s for s in app.screen_stack if isinstance(s, HelpScreen)), None + ) + assert help_screen is not None + assert help_screen._active_section_id == "query_insert" + assert help_screen._ordered[0].id == "query_insert" + @pytest.mark.asyncio async def test_nested_dialogs_both_block(self): """When multiple dialogs are stacked, outer actions should be blocked."""