├── tests ├── __init__.py ├── utils │ ├── __init__.py │ ├── common_prefix_test.py │ └── number_test.py ├── database │ ├── __init__.py │ ├── sort_order_test.py │ ├── scroll_direction_test.py │ ├── pager_test.schema.sql │ ├── offset_pager_test.py │ └── scroll_cursor_test.py ├── syntax │ ├── __init__.py │ ├── command │ │ ├── __init__.py │ │ ├── renderer_test.py │ │ └── parser_test.py │ └── documentation │ │ ├── __init__.py │ │ └── executor_test.py └── layouts │ └── table_test.py ├── clisnips ├── stores │ └── __init__.py ├── tui │ ├── __init__.py │ ├── layouts │ │ └── __init__.py │ ├── views │ │ └── __init__.py │ ├── widgets │ │ ├── __init__.py │ │ ├── dialogs │ │ │ ├── __init__.py │ │ │ ├── error.py │ │ │ └── confirm.py │ │ ├── progress │ │ │ ├── __init__.py │ │ │ ├── worker.py │ │ │ ├── process.py │ │ │ └── message_queue.py │ │ ├── table │ │ │ ├── __init__.py │ │ │ ├── cell.py │ │ │ ├── row.py │ │ │ ├── body.py │ │ │ ├── store.py │ │ │ └── input_processor.py │ │ ├── divider.py │ │ ├── field │ │ │ ├── flag.py │ │ │ ├── text.py │ │ │ ├── select.py │ │ │ ├── field.py │ │ │ ├── __init__.py │ │ │ ├── range.py │ │ │ └── path.py │ │ ├── utils.py │ │ ├── list_box.py │ │ ├── menu.py │ │ ├── spinner.py │ │ ├── radio.py │ │ ├── switch.py │ │ ├── combobox.py │ │ ├── dialog.py │ │ └── edit.py │ ├── components │ │ ├── __init__.py │ │ ├── pager_infos.py │ │ ├── layout_selector.py │ │ ├── sort_order_selector.py │ │ ├── page_size_input.py │ │ ├── sort_colum_selector.py │ │ ├── app_bar.py │ │ ├── list_options_dialog.py │ │ ├── delete_snippet_dialog.py │ │ ├── syntax_error_dialog.py │ │ ├── search_input.py │ │ ├── help_dialog.py │ │ ├── show_snippet_dialog.py │ │ ├── snippets_list.py │ │ └── snippets_table.py │ ├── highlighters │ │ ├── __init__.py │ │ ├── writer.py │ │ └── command.py │ ├── urwid_types.py │ ├── animation.py │ ├── loop.py │ ├── app.py │ ├── view.py │ └── tui.py ├── utils │ ├── __init__.py │ ├── list.py │ ├── common_prefix.py │ ├── iterable.py │ ├── function.py │ ├── clock.py │ └── number.py ├── __init__.py ├── cli │ ├── __init__.py │ ├── commands │ │ ├── version.py │ │ ├── config.py │ │ ├── optimize.py │ │ ├── dump.py │ │ ├── export.py │ │ ├── install_key_bindings.py │ │ ├── _import.py │ │ └── logs.py │ ├── command.py │ ├── parser.py │ ├── utils.py │ └── app.py ├── syntax │ ├── command │ │ ├── __init__.py │ │ ├── err.py │ │ ├── nodes.py │ │ ├── parser.py │ │ └── renderer.py │ ├── __init__.py │ ├── documentation │ │ ├── __init__.py │ │ ├── executor.py │ │ └── nodes.py │ ├── token.py │ └── llk_parser.py ├── ty.py ├── exporters │ ├── __init__.py │ ├── base.py │ ├── _json.py │ ├── toml.py │ └── xml.py ├── resources │ ├── key-bindings.zsh │ ├── key-bindings.bash │ └── schema.sql ├── __main__.py ├── importers │ ├── __init__.py │ ├── base.py │ ├── _json.py │ ├── toml.py │ ├── xml.py │ └── clicompanion.py ├── exceptions.py ├── log │ ├── tui.py │ └── cli.py ├── config │ ├── envs.py │ ├── settings.py │ ├── __init__.py │ ├── paths.py │ └── state.py ├── database │ ├── pager.py │ ├── __init__.py │ ├── search_pager.py │ └── offset_pager.py └── dic.py ├── .gitignore ├── .editorconfig ├── .vscode ├── settings.json └── launch.json ├── .github └── workflows │ ├── publish.yml │ ├── pre-publish.yml │ └── ci.yml ├── pyproject.toml ├── snippets └── quick-tour.toml ├── README.md └── docs ├── snippet-anatomy.md └── creating-snippets.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /clisnips/stores/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /clisnips/tui/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /clisnips/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/database/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/syntax/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /clisnips/tui/layouts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /clisnips/tui/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /clisnips/tui/widgets/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/syntax/command/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /clisnips/tui/components/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/syntax/documentation/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /clisnips/tui/widgets/dialogs/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /clisnips/tui/widgets/progress/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /clisnips/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.6.0' 2 | -------------------------------------------------------------------------------- /clisnips/cli/__init__.py: -------------------------------------------------------------------------------- 1 | from .app import Application 2 | 3 | __all__ = ('Application',) 4 | -------------------------------------------------------------------------------- /clisnips/syntax/command/__init__.py: -------------------------------------------------------------------------------- 1 | from .parser import parse # noqa: F401 (This is imported in the parent module) 2 | -------------------------------------------------------------------------------- /clisnips/ty.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from os import PathLike 4 | 5 | AnyPath = PathLike[str] | str 6 | -------------------------------------------------------------------------------- /clisnips/tui/widgets/table/__init__.py: -------------------------------------------------------------------------------- 1 | from .store import TableStore 2 | from .table import Table 3 | 4 | __all__ = ['TableStore', 'Table'] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.bak 3 | *.sqlite 4 | *.pyc 5 | __pycache__ 6 | .pytest_cache 7 | .mypy_cache 8 | 9 | /.idea 10 | .venv 11 | /dist 12 | /tmp 13 | -------------------------------------------------------------------------------- /clisnips/tui/highlighters/__init__.py: -------------------------------------------------------------------------------- 1 | from .command import highlight_command 2 | from .documentation import highlight_documentation 3 | 4 | __all__ = ['highlight_command', 'highlight_documentation'] 5 | -------------------------------------------------------------------------------- /clisnips/syntax/__init__.py: -------------------------------------------------------------------------------- 1 | from .command import parse as parse_command 2 | from .documentation import parse as parse_documentation 3 | 4 | __all__ = ( 5 | 'parse_command', 6 | 'parse_documentation', 7 | ) 8 | -------------------------------------------------------------------------------- /clisnips/utils/list.py: -------------------------------------------------------------------------------- 1 | from typing import TypeVar 2 | 3 | T = TypeVar('T') 4 | 5 | 6 | def pad_list(lst: list[T], pad_value: T, pad_len: int) -> list[T]: 7 | return lst + [pad_value] * (pad_len - len(lst)) 8 | -------------------------------------------------------------------------------- /clisnips/exporters/__init__.py: -------------------------------------------------------------------------------- 1 | from ._json import JsonExporter 2 | from .toml import TomlExporter 3 | from .xml import XmlExporter 4 | 5 | __all__ = ( 6 | 'XmlExporter', 7 | 'JsonExporter', 8 | 'TomlExporter', 9 | ) 10 | -------------------------------------------------------------------------------- /clisnips/tui/widgets/divider.py: -------------------------------------------------------------------------------- 1 | import urwid 2 | 3 | 4 | class HorizontalDivider(urwid.Divider): 5 | def __init__(self, margin_top: int = 0, margin_bottom: int = 0): 6 | super().__init__('─', top=margin_top, bottom=margin_bottom) 7 | -------------------------------------------------------------------------------- /clisnips/utils/common_prefix.py: -------------------------------------------------------------------------------- 1 | def common_prefix(*args: str) -> str: 2 | s1 = min(args) 3 | s2 = max(args) 4 | common = s1 5 | for i, char in enumerate(s1): 6 | if char != s2[i]: 7 | common = s1[:i] 8 | break 9 | return common 10 | -------------------------------------------------------------------------------- /clisnips/resources/key-bindings.zsh: -------------------------------------------------------------------------------- 1 | 2 | function __clisnips__() { 3 | local snip 4 | local ret 5 | snip="$(clisnips 2> "$(tty)")" 6 | ret=$? 7 | LBUFFER="${LBUFFER}${snip}" 8 | zle reset-prompt 9 | return $ret 10 | } 11 | zle -N __clisnips__ 12 | bindkey '\es' __clisnips__ 13 | -------------------------------------------------------------------------------- /clisnips/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from clisnips.cli import Application 4 | from clisnips.config.paths import ensure_app_dirs 5 | 6 | 7 | def main(): 8 | ensure_app_dirs() 9 | app = Application() 10 | sys.exit(app.run()) 11 | 12 | 13 | if __name__ == '__main__': 14 | main() 15 | -------------------------------------------------------------------------------- /clisnips/tui/urwid_types.py: -------------------------------------------------------------------------------- 1 | from typing import TypeAlias, TypeVar 2 | 3 | 4 | Text = TypeVar('Text', str, bytes) 5 | AttributedText: TypeAlias = Text | tuple[str, 'AttributedText[Text]'] 6 | UrwidMarkup: TypeAlias = AttributedText[Text] | list[AttributedText[Text]] 7 | 8 | TextMarkup: TypeAlias = UrwidMarkup[str] 9 | -------------------------------------------------------------------------------- /tests/database/sort_order_test.py: -------------------------------------------------------------------------------- 1 | from clisnips.database import SortOrder 2 | 3 | 4 | def test_reverse(): 5 | for orig, rev in ( 6 | (SortOrder.ASC, SortOrder.DESC), 7 | (SortOrder.DESC, SortOrder.ASC), 8 | ): 9 | assert orig.reversed() is rev, 'Failed to reverse sort order' 10 | -------------------------------------------------------------------------------- /clisnips/importers/__init__.py: -------------------------------------------------------------------------------- 1 | from ._json import JsonImporter 2 | from .clicompanion import CliCompanionImporter 3 | from .toml import TomlImporter 4 | from .xml import XmlImporter 5 | 6 | __all__ = ( 7 | 'JsonImporter', 8 | 'CliCompanionImporter', 9 | 'XmlImporter', 10 | 'TomlImporter', 11 | ) 12 | -------------------------------------------------------------------------------- /clisnips/syntax/documentation/__init__.py: -------------------------------------------------------------------------------- 1 | from clisnips.syntax.documentation.lexer import Lexer 2 | from clisnips.syntax.documentation.nodes import Documentation 3 | from clisnips.syntax.documentation.parser import Parser 4 | 5 | 6 | def parse(docstring: str) -> Documentation: 7 | return Parser(Lexer(docstring)).parse() 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | max_line_length = 120 7 | indent_style = space 8 | indent_size = 2 9 | tab_width = 4 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.{pyw,py}] 14 | indent_size = 4 15 | 16 | [Makefile] 17 | indent_style = tab 18 | -------------------------------------------------------------------------------- /clisnips/tui/highlighters/writer.py: -------------------------------------------------------------------------------- 1 | class UrwidMarkupWriter: 2 | def __init__(self): 3 | self._markup = [] 4 | 5 | def write(self, text_attr): 6 | self._markup.append(text_attr) 7 | 8 | def clear(self): 9 | self._markup = [] 10 | 11 | def get_markup(self): 12 | return self._markup 13 | -------------------------------------------------------------------------------- /clisnips/resources/key-bindings.bash: -------------------------------------------------------------------------------- 1 | # shellcheck shell=bash 2 | 3 | function __clisnips__() { 4 | local snip 5 | snip="$(clisnips 2> "$(tty)")" 6 | READLINE_LINE="${READLINE_LINE:0:$READLINE_POINT}${snip}${READLINE_LINE:$READLINE_POINT}" 7 | READLINE_POINT=$(( READLINE_POINT + ${#snip} )) 8 | } 9 | bind -x '"\es": "__clisnips__"' 10 | -------------------------------------------------------------------------------- /tests/database/scroll_direction_test.py: -------------------------------------------------------------------------------- 1 | from clisnips.database import ScrollDirection 2 | 3 | 4 | def test_reverse(): 5 | for orig, rev in ( 6 | (ScrollDirection.FWD, ScrollDirection.BWD), 7 | (ScrollDirection.BWD, ScrollDirection.FWD), 8 | ): 9 | assert orig.reversed() is rev, 'Failed to reverse scroll direction' 10 | -------------------------------------------------------------------------------- /clisnips/utils/iterable.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable 2 | from typing import TypeVar 3 | 4 | T = TypeVar('T') 5 | D = TypeVar('D') 6 | 7 | 8 | def intersperse(delimiter: D, iterable: Iterable[T]) -> Iterable[T | D]: 9 | it = iter(iterable) 10 | yield next(it) 11 | for x in it: 12 | yield delimiter 13 | yield x 14 | -------------------------------------------------------------------------------- /clisnips/cli/commands/version.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from clisnips import __version__ 4 | 5 | from clisnips.cli.command import Command 6 | 7 | 8 | def configure(parser: argparse.ArgumentParser): 9 | return VersionCommand 10 | 11 | 12 | class VersionCommand(Command): 13 | def run(self, argv) -> int: 14 | print(__version__) 15 | return 0 16 | -------------------------------------------------------------------------------- /clisnips/exporters/base.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod, ABC 2 | from pathlib import Path 3 | 4 | from clisnips.database.snippets_db import SnippetsDatabase 5 | 6 | 7 | class Exporter(ABC): 8 | def __init__(self, db: SnippetsDatabase): 9 | self._db = db 10 | 11 | @abstractmethod 12 | def export(self, path: Path): 13 | return NotImplemented 14 | -------------------------------------------------------------------------------- /clisnips/utils/function.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from typing import Any, Concatenate, TypeVar 3 | 4 | T = TypeVar('T') 5 | 6 | 7 | def bind(fn: Callable[Concatenate[T, ...], Any], obj: T, name: str | None = None): 8 | """ 9 | Turns `fn` into a bound method of `obj`, optionally renamed to `name`. 10 | """ 11 | setattr(obj, name or fn.__name__, fn.__get__(obj)) 12 | -------------------------------------------------------------------------------- /clisnips/exceptions.py: -------------------------------------------------------------------------------- 1 | class InvalidConfiguration(Exception): 2 | ... 3 | 4 | 5 | class ParseError(Exception): 6 | ... 7 | 8 | 9 | class CommandParseError(ParseError): 10 | def __init__(self, msg: str, cmd: str): 11 | super().__init__(msg) 12 | self.cmd = cmd 13 | 14 | 15 | class DocumentationParseError(ParseError): 16 | def __init__(self, msg: str): 17 | super().__init__(msg) 18 | -------------------------------------------------------------------------------- /clisnips/utils/clock.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Protocol 3 | 4 | 5 | class Clock(Protocol): 6 | def now(self) -> float: 7 | ... 8 | 9 | 10 | class MockClock(Clock): 11 | def __init__(self, now: float = 0.0) -> None: 12 | self._now = now 13 | 14 | def now(self) -> float: 15 | return self._now 16 | 17 | 18 | class SystemClock(Clock): 19 | def now(self) -> float: 20 | return time.time() 21 | -------------------------------------------------------------------------------- /clisnips/cli/command.py: -------------------------------------------------------------------------------- 1 | from clisnips.dic import DependencyInjectionContainer 2 | 3 | 4 | class Command: 5 | def __init__(self, dic: DependencyInjectionContainer): 6 | self.container = dic 7 | 8 | def run(self, argv) -> int: 9 | return NotImplemented 10 | 11 | def print(self, *args, stderr: bool = False, end: str = '\n', sep: str = ' '): 12 | self.container.markup_helper.print(*args, stderr=stderr, end=end, sep=sep) 13 | -------------------------------------------------------------------------------- /clisnips/log/tui.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | 4 | 5 | def configure(log_file: Path, level: str | None = None): 6 | match level: 7 | case None | '': 8 | logging.basicConfig(handlers=(logging.NullHandler(),)) 9 | case _: 10 | from logging.handlers import SocketHandler 11 | 12 | handler = SocketHandler(str(log_file), None) 13 | logging.basicConfig(level=level.upper(), handlers=(handler,)) 14 | -------------------------------------------------------------------------------- /clisnips/config/envs.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | from clisnips.ty import AnyPath 5 | 6 | 7 | def _db_path_from_env() -> AnyPath | None: 8 | match os.environ.get('CLISNIPS_DB'): 9 | case '' | None: 10 | return None 11 | case ':memory:': 12 | return ':memory:' 13 | case v: 14 | p = Path(v).expanduser().absolute() 15 | if p.is_file(): 16 | return p 17 | return None 18 | 19 | 20 | DB_PATH = _db_path_from_env() 21 | -------------------------------------------------------------------------------- /tests/utils/common_prefix_test.py: -------------------------------------------------------------------------------- 1 | from clisnips.utils.common_prefix import common_prefix 2 | 3 | 4 | def test_it_works_with_sorted_sequence(): 5 | choices = ['geek', 'geeks', 'geeze'] 6 | assert common_prefix(*choices) == 'gee' 7 | 8 | 9 | def test_it_works_with_unsorted_sequence(): 10 | choices = ['.gitignore', '.github', '.gitlab-ci.yml'] 11 | assert common_prefix(*choices) == '.git' 12 | 13 | 14 | def test_it_returns_empty_string_when_no_common_prefix(): 15 | choices = ['foo', 'bar', 'foobar', 'baz'] 16 | assert common_prefix(*choices) == '' 17 | -------------------------------------------------------------------------------- /clisnips/tui/widgets/table/cell.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import urwid 6 | from urwid.widget.constants import WrapMode 7 | 8 | from clisnips.tui.layouts.table import LayoutColumn 9 | 10 | # avoid circular imports 11 | if TYPE_CHECKING: 12 | from .row import Row 13 | 14 | 15 | class Cell(urwid.Text): 16 | def __init__(self, row: Row, column: LayoutColumn, content): 17 | self._row = row 18 | self._column = column 19 | super().__init__(content, wrap=WrapMode.SPACE if column.word_wrap else WrapMode.ANY) 20 | -------------------------------------------------------------------------------- /clisnips/tui/components/pager_infos.py: -------------------------------------------------------------------------------- 1 | import urwid 2 | 3 | from clisnips.stores.snippets import SnippetsStore, State 4 | 5 | 6 | class PagerInfos(urwid.Text): 7 | def __init__(self, store: SnippetsStore): 8 | super().__init__('Page 1/1 (0)', align='right') 9 | 10 | def compute_message(state: State): 11 | return f'Page {state["current_page"]}/{state["page_count"]} ({state["total_rows"]})' 12 | 13 | self._watcher = store.watch( 14 | compute_message, 15 | lambda text: self.set_text(text), 16 | immediate=True, 17 | ) 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[python]": { 3 | "diffEditor.ignoreTrimWhitespace": false, 4 | "gitlens.codeLens.symbolScopes": [ 5 | "!Module" 6 | ], 7 | "editor.formatOnSave": true, 8 | "editor.formatOnSaveMode": "file", 9 | "editor.wordBasedSuggestions": false 10 | }, 11 | "python.testing.pytestArgs": [ 12 | "tests" 13 | ], 14 | "python.testing.unittestEnabled": false, 15 | "python.testing.pytestEnabled": true, 16 | "python.analysis.typeCheckingMode": "basic", 17 | "python.analysis.stubPath": "typings", 18 | "python.analysis.useLibraryCodeForTypes": true, 19 | } 20 | -------------------------------------------------------------------------------- /clisnips/tui/widgets/table/row.py: -------------------------------------------------------------------------------- 1 | import urwid 2 | 3 | from .cell import Cell 4 | 5 | 6 | class Row(urwid.Columns): 7 | def __init__(self, layout_row): 8 | cols = [] 9 | for column, value in layout_row: 10 | cell = Cell(layout_row, column, value) 11 | cell = urwid.AttrMap(cell, column.attr_map) 12 | cols.append((column.computed_width, cell)) 13 | super().__init__(cols, dividechars=1) 14 | 15 | def selectable(self) -> bool: 16 | return True 17 | 18 | def keypress(self, size: tuple[int] | tuple[int, int], key: str) -> str | None: 19 | return key 20 | -------------------------------------------------------------------------------- /clisnips/config/settings.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, ConfigDict, Field 2 | 3 | from .palette import PaletteModel, default_palette 4 | from .paths import get_data_path 5 | 6 | 7 | class AppSettings(BaseModel): 8 | model_config = ConfigDict(title='Clisnips configuration settings.') 9 | database: str = Field( 10 | title='Path to the snippets SQLite database', 11 | default_factory=lambda: str(get_data_path('snippets.sqlite')), 12 | ) 13 | palette: PaletteModel = Field( # type: ignore 14 | title='The application color palette', 15 | default_factory=lambda: PaletteModel(**default_palette), 16 | json_schema_extra={'default': {}}, 17 | ) 18 | -------------------------------------------------------------------------------- /clisnips/tui/widgets/field/flag.py: -------------------------------------------------------------------------------- 1 | import urwid 2 | 3 | from clisnips.tui.urwid_types import TextMarkup 4 | 5 | from .field import Entry, SimpleField 6 | 7 | 8 | class FlagField(SimpleField[str]): 9 | def __init__(self, label: TextMarkup, *args, **kwargs): 10 | entry = FlagEntry(*args, **kwargs) 11 | super().__init__(label, entry) 12 | 13 | 14 | class FlagEntry(Entry[str], urwid.CheckBox): 15 | def __init__(self, flag: str): 16 | self._flag = flag 17 | super().__init__(flag) 18 | urwid.connect_signal(self, 'postchange', lambda *x: self._emit('changed')) 19 | 20 | def get_value(self) -> str: 21 | return self._flag if self.get_state() else '' 22 | -------------------------------------------------------------------------------- /clisnips/tui/widgets/field/text.py: -------------------------------------------------------------------------------- 1 | import urwid 2 | 3 | from clisnips.tui.urwid_types import TextMarkup 4 | from clisnips.tui.widgets.edit import EmacsEdit 5 | 6 | from .field import Entry, SimpleField 7 | 8 | 9 | class TextField(SimpleField[str]): 10 | def __init__(self, label: TextMarkup, *args, **kwargs): 11 | entry = TextEntry(*args, **kwargs) 12 | super().__init__(label, entry) 13 | 14 | 15 | class TextEntry(Entry[str], EmacsEdit): 16 | def __init__(self, default: str = ''): 17 | super().__init__('', default) 18 | urwid.connect_signal(self, 'postchange', lambda *x: self._emit('changed')) 19 | 20 | def get_value(self) -> str: 21 | return self.get_edit_text() 22 | -------------------------------------------------------------------------------- /clisnips/exporters/_json.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import time 4 | from pathlib import Path 5 | 6 | from clisnips.exporters.base import Exporter 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class JsonExporter(Exporter): 12 | def export(self, path: Path): 13 | start_time = time.time() 14 | num_rows = len(self._db) 15 | logger.info(f'Converting {num_rows:n} snippets to JSON') 16 | 17 | with open(path, 'w') as fp: 18 | json.dump([dict(row) for row in self._db], fp, indent=2) 19 | 20 | elapsed_time = time.time() - start_time 21 | logger.info(f'Exported {num_rows:n} snippets in {elapsed_time:.1f} seconds.', extra={'color': 'success'}) 22 | -------------------------------------------------------------------------------- /clisnips/cli/commands/config.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import json 3 | import sys 4 | 5 | from clisnips.config.settings import AppSettings 6 | 7 | from clisnips.cli.command import Command 8 | 9 | 10 | def configure(cmd: argparse.ArgumentParser): 11 | cmd.add_argument('--schema', action='store_true', help='Shows the JSON schema for the configuration.') 12 | 13 | return ConfigCommand 14 | 15 | 16 | class ConfigCommand(Command): 17 | def run(self, argv) -> int: 18 | if argv.schema: 19 | schema = AppSettings.model_json_schema() 20 | json.dump(schema, sys.stdout, indent=2) 21 | else: 22 | self.container.config.write(sys.stdout) 23 | 24 | sys.stdout.write('\n') 25 | return 0 26 | -------------------------------------------------------------------------------- /clisnips/syntax/command/err.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterator 2 | 3 | from .nodes import Field 4 | 5 | 6 | class CommandTemplateError(Exception): 7 | ... 8 | 9 | 10 | class InterpolationError(CommandTemplateError): 11 | def __init__(self, msg: str, field: Field) -> None: 12 | self.field = field 13 | super().__init__(msg) 14 | 15 | 16 | class InvalidContext(InterpolationError): 17 | ... 18 | 19 | 20 | class InterpolationErrorGroup(ExceptionGroup[InterpolationError]): 21 | def __iter__(self) -> Iterator[InterpolationError]: 22 | for err in self.exceptions: 23 | if isinstance(err, InterpolationError): 24 | yield err 25 | elif isinstance(err, self.__class__): 26 | yield from err 27 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish on PyPI 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: 7 | - published 8 | 9 | jobs: 10 | publish: 11 | runs-on: ubuntu-latest 12 | continue-on-error: false 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Install poetry 16 | run: pipx install poetry 17 | - name: Setup Python 18 | uses: actions/setup-python@v4 19 | with: 20 | python-version: "3.11" 21 | cache: poetry 22 | - name: Install 23 | run: poetry install 24 | - name: test 25 | run: poetry run pytest 26 | - name: Publish to PyPI 27 | run: | 28 | poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }} 29 | poetry publish --build 30 | -------------------------------------------------------------------------------- /tests/database/pager_test.schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE paging_test( 2 | value TEXT, 3 | ranking INTEGER 4 | ); 5 | 6 | CREATE VIRTUAL TABLE paging_test_idx USING fts4( 7 | content="paging_test", 8 | value 9 | ); 10 | 11 | CREATE TRIGGER test_bu BEFORE UPDATE ON paging_test 12 | BEGIN 13 | DELETE FROM paging_test_idx WHERE docid=OLD.rowid; 14 | END; 15 | 16 | CREATE TRIGGER test_bd BEFORE DELETE ON paging_test 17 | BEGIN 18 | DELETE FROM paging_test_idx WHERE docid=OLD.rowid; 19 | END; 20 | 21 | CREATE TRIGGER test_au AFTER UPDATE ON paging_test 22 | BEGIN 23 | INSERT INTO paging_test_idx(docid, value) VALUES(NEW.rowid, NEW.value); 24 | END; 25 | 26 | CREATE TRIGGER test_ai AFTER INSERT ON paging_test 27 | BEGIN 28 | INSERT INTO paging_test_idx(docid, value) VALUES(NEW.rowid, NEW.value); 29 | END; 30 | -------------------------------------------------------------------------------- /clisnips/importers/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from pathlib import Path 3 | from typing_extensions import TypedDict 4 | 5 | from pydantic import TypeAdapter 6 | 7 | from clisnips.database import ImportableSnippet 8 | from clisnips.database.snippets_db import SnippetsDatabase 9 | 10 | 11 | class Importer(ABC): 12 | def __init__(self, db: SnippetsDatabase, dry_run=False): 13 | self._db = db 14 | self._dry_run = dry_run 15 | 16 | @abstractmethod 17 | def import_path(self, path: Path) -> None: 18 | return NotImplemented 19 | 20 | 21 | class SnippetDocument(TypedDict): 22 | snippets: list[ImportableSnippet] 23 | 24 | 25 | SnippetAdapter = TypeAdapter(ImportableSnippet) 26 | SnippetListAdapter = TypeAdapter(list[ImportableSnippet]) 27 | SnippetDocumentAdapter = TypeAdapter(SnippetDocument) 28 | -------------------------------------------------------------------------------- /clisnips/tui/components/layout_selector.py: -------------------------------------------------------------------------------- 1 | import urwid 2 | 3 | from clisnips.stores.snippets import ListLayout, SnippetsStore 4 | from clisnips.tui.widgets.switch import Switch 5 | 6 | 7 | class LayoutSelector(urwid.WidgetWrap): 8 | def __init__(self, store: SnippetsStore): 9 | switch = Switch( 10 | caption='Layout: ', 11 | labels={Switch.State.OFF: 'list', Switch.State.ON: 'table'}, 12 | states={Switch.State.OFF: ListLayout.LIST, Switch.State.ON: ListLayout.TABLE}, 13 | ) 14 | super().__init__(switch) 15 | 16 | urwid.connect_signal(switch, Switch.Signals.CHANGED, lambda _, v: store.change_layout(v)) 17 | self._watcher = store.watch( 18 | lambda s: s['list_layout'], 19 | lambda v: switch.set_value(v), 20 | immediate=True, 21 | ) 22 | -------------------------------------------------------------------------------- /clisnips/tui/components/sort_order_selector.py: -------------------------------------------------------------------------------- 1 | import urwid 2 | 3 | from clisnips.database import SortOrder 4 | from clisnips.stores.snippets import SnippetsStore 5 | from clisnips.tui.widgets.switch import Switch 6 | 7 | 8 | class SortOrderSelector(urwid.WidgetWrap): 9 | def __init__(self, store: SnippetsStore): 10 | switch = Switch( 11 | caption='Order: ', 12 | labels={Switch.State.OFF: '↑ ASC', Switch.State.ON: 'DESC ↓'}, 13 | states={Switch.State.OFF: SortOrder.ASC, Switch.State.ON: SortOrder.DESC}, 14 | ) 15 | super().__init__(switch) 16 | 17 | urwid.connect_signal(switch, Switch.Signals.CHANGED, lambda _, v: store.change_sort_order(v)) 18 | self._watcher = store.watch( 19 | lambda s: s['sort_order'], 20 | lambda v: switch.set_value(v), 21 | immediate=True, 22 | ) 23 | -------------------------------------------------------------------------------- /clisnips/importers/_json.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from pathlib import Path 4 | 5 | from .base import Importer, SnippetListAdapter 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class JsonImporter(Importer): 11 | def import_path(self, path: Path): 12 | start_time = time.time() 13 | logger.info(f'Importing snippets from {path}') 14 | 15 | with open(path) as fp: 16 | data = SnippetListAdapter.validate_json(fp.read()) 17 | if not self._dry_run: 18 | self._db.insert_many(data) 19 | logger.info('Rebuilding & optimizing search index') 20 | if not self._dry_run: 21 | self._db.rebuild_index() 22 | self._db.optimize_index() 23 | 24 | elapsed_time = time.time() - start_time 25 | logger.info(f'Imported in {elapsed_time:.1f} seconds.', extra={'color': 'success'}) 26 | -------------------------------------------------------------------------------- /.github/workflows/pre-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish on TestPyPI 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - '[0-9]+.[0-9]+.[0-9]+' 8 | 9 | jobs: 10 | publish: 11 | runs-on: ubuntu-latest 12 | continue-on-error: false 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Install poetry 16 | run: pipx install poetry 17 | - name: Setup Python 18 | uses: actions/setup-python@v4 19 | with: 20 | python-version: "3.11" 21 | cache: poetry 22 | - name: Install 23 | run: poetry install 24 | - name: Test 25 | run: poetry run pytest 26 | - name: Publish to TestPyPI 27 | run: | 28 | poetry config repositories.test-pypi https://test.pypi.org/legacy/ 29 | poetry config pypi-token.test-pypi ${{ secrets.TEST_PYPI_TOKEN }} 30 | poetry publish --build -r test-pypi 31 | -------------------------------------------------------------------------------- /clisnips/tui/components/page_size_input.py: -------------------------------------------------------------------------------- 1 | import urwid 2 | from urwid.numedit import IntegerEdit 3 | 4 | from clisnips.stores.snippets import SnippetsStore 5 | from clisnips.tui.widgets.utils import suspend_emitter 6 | 7 | 8 | class PageSizeInput(IntegerEdit): 9 | def __init__(self, store: SnippetsStore, caption: str): 10 | super().__init__(caption) 11 | 12 | def on_state_changed(value: int): 13 | with suspend_emitter(self): 14 | self.set_edit_text(str(value)) 15 | 16 | def on_value_changed(*_): 17 | value = self.value() 18 | if value is not None: 19 | store.change_page_size(max(1, min(100, int(value)))) 20 | 21 | urwid.connect_signal(self, 'postchange', on_value_changed) 22 | self._watchers = { 23 | 'value': store.watch(lambda s: s['page_size'], on_state_changed, immediate=True), 24 | } 25 | -------------------------------------------------------------------------------- /clisnips/tui/components/sort_colum_selector.py: -------------------------------------------------------------------------------- 1 | import urwid 2 | 3 | from clisnips.database import SortColumn 4 | from clisnips.stores.snippets import SnippetsStore 5 | from clisnips.tui.widgets.radio import RadioGroup 6 | 7 | choices = { 8 | SortColumn.RANKING: 'Sort by popularity', 9 | SortColumn.USAGE_COUNT: 'Sort by usage count', 10 | SortColumn.LAST_USED_AT: 'Sort by last usage date', 11 | SortColumn.CREATED_AT: 'Sort by creation date', 12 | } 13 | 14 | 15 | class SortColumnSelector(urwid.Pile): 16 | def __init__(self, store: SnippetsStore): 17 | group = RadioGroup(choices) 18 | urwid.connect_signal(group, RadioGroup.Signals.CHANGED, lambda v: store.change_sort_column(v)) 19 | self._watcher = store.watch( 20 | lambda s: s['sort_by'], 21 | lambda v: group.set_value(v), 22 | immediate=True, 23 | ) 24 | super().__init__(group) 25 | -------------------------------------------------------------------------------- /clisnips/cli/commands/optimize.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | import time 4 | 5 | from clisnips.cli.command import Command 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | def configure(cmd: argparse.ArgumentParser): 11 | cmd.add_argument('--rebuild', action='store_true', help='Rebuilds the search index before optimizing.') 12 | 13 | return OptimizeCommand 14 | 15 | 16 | class OptimizeCommand(Command): 17 | def run(self, argv) -> int: 18 | start_time = time.time() 19 | 20 | db = self.container.database 21 | if argv.rebuild: 22 | logger.info('Rebuilding search index') 23 | db.rebuild_index() 24 | 25 | logger.info('Optimizing search index') 26 | db.optimize_index() 27 | 28 | elapsed = time.time() - start_time 29 | logger.info(f'Done in {elapsed:.1f} seconds.', extra={'color': 'success'}) 30 | return 0 31 | -------------------------------------------------------------------------------- /clisnips/tui/components/app_bar.py: -------------------------------------------------------------------------------- 1 | import urwid 2 | 3 | from clisnips.stores.snippets import SnippetsStore 4 | 5 | 6 | class KeyBinding(urwid.Text): 7 | def __init__(self, key: str, label: str) -> None: 8 | super().__init__( 9 | [ 10 | ('help:key', key), 11 | ('view:default', f': {label}'), 12 | ] 13 | ) 14 | 15 | 16 | class AppBar(urwid.WidgetWrap): 17 | def __init__(self, store: SnippetsStore): 18 | left = urwid.Columns( 19 | ( 20 | ('pack', KeyBinding('F1', 'Help')), 21 | ('pack', KeyBinding('F2', 'Options')), 22 | ), 23 | dividechars=1, 24 | ) 25 | sections = urwid.Columns( 26 | ( 27 | left, 28 | ('pack', KeyBinding('ESC', 'Quit')), 29 | ), 30 | dividechars=1, 31 | ) 32 | super().__init__(sections) 33 | -------------------------------------------------------------------------------- /clisnips/syntax/documentation/executor.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from types import CodeType 3 | from typing import Any 4 | 5 | from .nodes import Documentation 6 | 7 | 8 | class Executor: 9 | def __init__(self, doc: Documentation) -> None: 10 | self._doc = doc 11 | self._bytecode_cache: list[CodeType] = [] 12 | 13 | @staticmethod 14 | def compile_str(code: str) -> CodeType: 15 | return compile(code, '', 'exec') 16 | 17 | def execute(self, context: dict[str, Any]) -> dict[str, Any]: 18 | if not self._doc.code_blocks: 19 | return context 20 | if not self._bytecode_cache: 21 | self._compile_cache() 22 | ctx = deepcopy(context) 23 | for code in self._bytecode_cache: 24 | exec(code, ctx) 25 | return ctx 26 | 27 | def _compile_cache(self): 28 | self._bytecode_cache = [self.compile_str(b.code) for b in self._doc.code_blocks] 29 | -------------------------------------------------------------------------------- /clisnips/cli/parser.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | from argparse import ArgumentParser, Namespace 3 | from collections.abc import Callable 4 | from typing import Self 5 | 6 | from .command import Command 7 | 8 | 9 | class LazySubParser(ArgumentParser): 10 | def __init__(self, *args, **kwargs) -> None: 11 | super().__init__(*args, **kwargs) 12 | self._initialized = False 13 | 14 | def parse_known_args(self, args: list[str], namespace: Namespace) -> tuple[Namespace, list[str]]: 15 | if not self._initialized: 16 | self._initialized = True 17 | self._load_command(self._defaults['__module__']) 18 | 19 | return super().parse_known_args(args, namespace) 20 | 21 | def _load_command(self, mod: str): 22 | module = importlib.import_module(f'clisnips.cli.commands.{mod}') 23 | configure: Callable[[Self], type[Command]] = getattr(module, 'configure') 24 | self._defaults['__command__'] = configure(self) 25 | -------------------------------------------------------------------------------- /clisnips/importers/toml.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from pathlib import Path 4 | 5 | import tomllib 6 | 7 | from .base import Importer, SnippetDocumentAdapter 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class TomlImporter(Importer): 13 | def import_path(self, path: Path) -> None: 14 | start_time = time.time() 15 | logger.info(f'Importing snippets from {path}') 16 | 17 | with open(path, 'rb') as fp: 18 | data = SnippetDocumentAdapter.validate_python(tomllib.load(fp)) 19 | if not self._dry_run: 20 | self._db.insert_many(data['snippets']) 21 | logger.info('Rebuilding & optimizing search index') 22 | if not self._dry_run: 23 | self._db.rebuild_index() 24 | self._db.optimize_index() 25 | 26 | elapsed_time = time.time() - start_time 27 | logger.info(f'Imported in {elapsed_time:.1f} seconds.', extra={'color': 'success'}) 28 | -------------------------------------------------------------------------------- /clisnips/tui/components/list_options_dialog.py: -------------------------------------------------------------------------------- 1 | import urwid 2 | 3 | from clisnips.stores.snippets import SnippetsStore 4 | from clisnips.tui.components.layout_selector import LayoutSelector 5 | from clisnips.tui.components.page_size_input import PageSizeInput 6 | from clisnips.tui.components.sort_colum_selector import SortColumnSelector 7 | from clisnips.tui.components.sort_order_selector import SortOrderSelector 8 | from clisnips.tui.widgets.dialog import Dialog 9 | from clisnips.tui.widgets.divider import HorizontalDivider 10 | 11 | 12 | class ListOptionsDialog(Dialog): 13 | def __init__(self, parent, store: SnippetsStore): 14 | contents = ( 15 | LayoutSelector(store), 16 | HorizontalDivider(), 17 | SortColumnSelector(store), 18 | SortOrderSelector(store), 19 | HorizontalDivider(), 20 | PageSizeInput(store, 'Page size: '), 21 | ) 22 | super().__init__(parent, urwid.ListBox(urwid.SimpleListWalker(contents))) 23 | -------------------------------------------------------------------------------- /clisnips/tui/widgets/utils.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import types 3 | 4 | from urwid import signals 5 | 6 | 7 | def original_widget(widget, recursive=False): 8 | while hasattr(widget, 'original_widget'): 9 | widget = widget.original_widget 10 | if not recursive: 11 | return widget 12 | return widget 13 | 14 | 15 | @contextlib.contextmanager 16 | def suspend_emitter(subject): 17 | emit = signals.emit_signal 18 | 19 | def suspended(self, obj, *rest): 20 | if subject is obj: 21 | return False 22 | return emit(self, obj, *rest) 23 | 24 | signals.emit_signal = types.MethodType(suspended, emit.__self__) 25 | try: 26 | yield 27 | finally: 28 | signals.emit_signal = emit 29 | 30 | 31 | @contextlib.contextmanager 32 | def suspend_signals(): 33 | emit = signals.emit_signal 34 | signals.emit_signal = lambda *args: False 35 | try: 36 | yield 37 | finally: 38 | signals.emit_signal = emit 39 | -------------------------------------------------------------------------------- /clisnips/tui/widgets/field/select.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable 2 | from decimal import Decimal 3 | from typing import TypeAlias 4 | 5 | from clisnips.tui.urwid_types import TextMarkup 6 | from clisnips.tui.widgets.combobox import ComboBox 7 | 8 | from .field import Entry, SimpleField 9 | 10 | 11 | Value: TypeAlias = str | Decimal 12 | 13 | 14 | class SelectField(SimpleField[Value]): 15 | def __init__(self, label: TextMarkup, *args, **kwargs): 16 | entry = SelectEntry(*args, **kwargs) 17 | super().__init__(label, entry) 18 | 19 | 20 | class SelectEntry(Entry[Value], ComboBox[Value]): 21 | def __init__(self, choices: Iterable[Value] | None = None, default: int = 0): 22 | super().__init__() 23 | if choices: 24 | for i, choice in enumerate(choices): 25 | self.append(str(choice), choice, i == default) 26 | 27 | def get_value(self) -> Value: 28 | # TODO: this may need to be generic 29 | return self.get_selected() or '' 30 | -------------------------------------------------------------------------------- /clisnips/tui/widgets/list_box.py: -------------------------------------------------------------------------------- 1 | import urwid 2 | 3 | 4 | class CyclingFocusListBox(urwid.ListBox): 5 | def keypress(self, size: tuple[int, int], key: str): 6 | match (key, self.focus_position): 7 | case ('up' | 'k', 0): 8 | self.focus_position = len(self) - 1 9 | self.set_focus_valign('bottom') 10 | case ('down' | 'j', p) if p == len(self) - 1: 11 | self.focus_position = 0 12 | self.set_focus_valign('top') 13 | # TODO: figure-out the best semantics for vi key bindings 14 | case ('j', _): 15 | return super().keypress(size, 'down') 16 | case ('k', _): 17 | return super().keypress(size, 'up') 18 | # case ('h', _): 19 | # return super().keypress(size, 'page up') 20 | # case ('l', _): 21 | # return super().keypress(size, 'page down') 22 | case _: 23 | return super().keypress(size, key) 24 | -------------------------------------------------------------------------------- /clisnips/tui/widgets/table/body.py: -------------------------------------------------------------------------------- 1 | import urwid 2 | 3 | from clisnips.tui.widgets.list_box import CyclingFocusListBox 4 | from clisnips.tui.widgets.utils import original_widget 5 | 6 | 7 | class Body(CyclingFocusListBox): 8 | KEYPRESS = 'keypress' 9 | 10 | _body: urwid.SimpleFocusListWalker 11 | 12 | def __init__(self): 13 | self._rows = [] 14 | urwid.register_signal(self.__class__, self.KEYPRESS) 15 | super().__init__(urwid.SimpleFocusListWalker([])) 16 | 17 | def clear(self): 18 | self._body.clear() 19 | 20 | def append(self, row): 21 | self._body.append(row) 22 | 23 | def __iter__(self): 24 | yield from self._body 25 | 26 | @property 27 | def focused_row(self) -> urwid.Widget: 28 | row, index = self._body.get_focus() 29 | return original_widget(row) 30 | 31 | def keypress(self, size, key): 32 | self._emit(self.KEYPRESS, size, key) 33 | if key in ('up', 'down', 'left', 'right'): 34 | return super().keypress(size, key) 35 | return key 36 | -------------------------------------------------------------------------------- /clisnips/tui/widgets/dialogs/error.py: -------------------------------------------------------------------------------- 1 | from traceback import format_exc 2 | 3 | import urwid 4 | 5 | from clisnips.tui.widgets.dialog import Dialog, ResponseKind 6 | from clisnips.tui.widgets.divider import HorizontalDivider 7 | 8 | 9 | def _format_error_message(err: Exception) -> str: 10 | return str(err) 11 | 12 | 13 | def _format_stack_trace(err: Exception) -> str: 14 | return format_exc() 15 | 16 | 17 | class ErrorDialog(Dialog): 18 | def __init__(self, parent, err: Exception): 19 | message = _format_error_message(err) 20 | details = _format_stack_trace(err) 21 | 22 | body = urwid.Pile( 23 | [ 24 | urwid.Text(('error', message)), 25 | HorizontalDivider(), 26 | urwid.Text(('default', details)), 27 | ] 28 | ) 29 | 30 | super().__init__(parent, body) 31 | self.set_actions( 32 | Dialog.Action('OK', ResponseKind.ACCEPT), 33 | ) 34 | self._frame.focus_position = 1 35 | urwid.connect_signal(self, Dialog.Signals.RESPONSE, lambda *x: self.close()) 36 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Build and test 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - master 8 | pull_request: 9 | branches: 10 | - master 11 | 12 | jobs: 13 | test: 14 | name: Test py@${{matrix.python-version}} / ${{matrix.os}} 15 | runs-on: ${{matrix.os}} 16 | continue-on-error: false 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | os: 21 | - ubuntu-latest 22 | python-version: 23 | - "3.11" 24 | steps: 25 | - uses: actions/checkout@v4 26 | # https://github.com/actions/setup-python/issues/659 27 | - name: Install poetry 28 | run: pipx install poetry 29 | - name: Setup Python 30 | uses: actions/setup-python@v4 31 | with: 32 | python-version: ${{matrix.python-version}} 33 | cache: poetry 34 | - name: Install dependencies 35 | run: poetry install 36 | - name: Check style 37 | run: poetry run ruff format --check . 38 | - name: Lint 39 | run: poetry run ruff check . 40 | - name: Test 41 | run: poetry run pytest 42 | -------------------------------------------------------------------------------- /clisnips/tui/animation.py: -------------------------------------------------------------------------------- 1 | import time 2 | from collections.abc import Callable 3 | 4 | from .loop import clear_timeout, set_timeout 5 | 6 | 7 | class AnimationController: 8 | def __init__(self, callback: Callable, frame_rate: int = 60): 9 | self._callback = callback 10 | self._frame_duration = 1000 / frame_rate 11 | self._timeout_handle = None 12 | self._last_update = 0.0 13 | 14 | def toggle(self): 15 | self.stop() if self._timeout_handle else self.start() 16 | 17 | def start(self, *args): 18 | self._last_update = time.time() * 1000 19 | 20 | def loop(*cb_args): 21 | now = time.time() * 1000 22 | delta = (now - self._last_update) / self._frame_duration 23 | self._callback(delta, *cb_args) 24 | self._last_update = now 25 | self._timeout_handle = set_timeout(self._frame_duration, loop, *cb_args) 26 | 27 | self._timeout_handle = set_timeout(self._frame_duration, loop, *args) 28 | 29 | def stop(self): 30 | if self._timeout_handle: 31 | clear_timeout(self._timeout_handle) 32 | self._timeout_handle = None 33 | -------------------------------------------------------------------------------- /clisnips/tui/components/delete_snippet_dialog.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | 3 | import urwid 4 | from urwid.widget.constants import Align, VAlign, WHSettings 5 | 6 | from clisnips.tui.widgets.dialog import Dialog, ResponseKind 7 | 8 | 9 | class DeleteSnippetDialog(Dialog): 10 | def __init__(self, parent): 11 | text = urwid.Text('Are you sure you want to delete this snippet ?') 12 | body = urwid.Filler(urwid.Padding(text, width=WHSettings.PACK, align=Align.CENTER), valign=VAlign.MIDDLE) 13 | super().__init__(parent, body) 14 | 15 | self.set_actions( 16 | Dialog.Action('Cancel', ResponseKind.REJECT), 17 | Dialog.Action('Confirm', ResponseKind.ACCEPT, Dialog.Action.Kind.DESTRUCTIVE), 18 | ) 19 | self._frame.focus_position = 1 20 | urwid.connect_signal(self, Dialog.Signals.RESPONSE, lambda *x: self.close()) 21 | 22 | def on_accept(self, callback: Callable, *args): 23 | def handler(dialog, response_type): 24 | if response_type == ResponseKind.ACCEPT: 25 | callback(*args) 26 | 27 | urwid.connect_signal(self, Dialog.Signals.RESPONSE, handler) 28 | -------------------------------------------------------------------------------- /clisnips/tui/widgets/menu.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | import urwid 4 | 5 | from .list_box import CyclingFocusListBox 6 | 7 | 8 | class PopupMenu(urwid.WidgetWrap): 9 | class Signals(enum.StrEnum): 10 | CLOSED = enum.auto() 11 | 12 | signals = list(Signals) 13 | 14 | def __init__(self, title=''): 15 | self._walker = urwid.SimpleFocusListWalker([]) 16 | lb = urwid.LineBox( 17 | CyclingFocusListBox(self._walker), 18 | title=title, 19 | title_align='left', 20 | tlcorner='┌', tline='╌', trcorner='┐', 21 | lline='┆', rline='┆', 22 | blcorner='└', bline='╌', brcorner='┘', 23 | ) # fmt: skip 24 | super().__init__(urwid.AttrMap(lb, 'popup-menu')) 25 | 26 | def set_items(self, items): 27 | self._walker[:] = items 28 | 29 | def append(self, item): 30 | self._walker.append(item) 31 | 32 | def keypress(self, size, key): 33 | if key == 'esc': 34 | self._emit(PopupMenu.Signals.CLOSED) 35 | return 36 | return super().keypress(size, key) 37 | 38 | def __len__(self): 39 | return len(self._walker) 40 | -------------------------------------------------------------------------------- /clisnips/syntax/token.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Generic, TypeVar 3 | 4 | Kind = TypeVar('Kind', bound=Enum) 5 | 6 | 7 | class Token(Generic[Kind]): 8 | __slots__ = ('kind', 'value', 'start_line', 'start_col', 'end_line', 'end_col', 'start_pos', 'end_pos') 9 | 10 | @property 11 | def name(self) -> str: 12 | return self.kind.name 13 | 14 | def __init__(self, kind: Kind, start_line: int, start_col: int, value: str = ''): 15 | self.kind = kind 16 | self.start_line = start_line 17 | self.start_col = start_col 18 | self.value = value 19 | self.end_line = start_line 20 | self.end_col = start_col 21 | self.start_pos = self.end_pos = -1 22 | 23 | def __str__(self): 24 | return f'{self.kind.name} {self.value!r} on line {self.start_line}, column {self.start_col}' 25 | 26 | def __repr__(self): 27 | pos = '' 28 | if self.start_pos >= 0: 29 | pos = f' {self.start_pos}->{self.end_pos}' 30 | return ( 31 | f'({self.end_line},{self.end_col}) : {self.value!r}>' 33 | ) 34 | -------------------------------------------------------------------------------- /clisnips/exporters/toml.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from pathlib import Path 4 | 5 | import tomlkit 6 | 7 | from .base import Exporter 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class TomlExporter(Exporter): 13 | def export(self, path: Path): 14 | start_time = time.time() 15 | num_rows = len(self._db) 16 | logger.info(f'Converting {num_rows:n} snippets to TOML') 17 | 18 | document = tomlkit.document() 19 | items = tomlkit.aot() 20 | for row in self._db: 21 | tbl = tomlkit.table() 22 | tbl.update(row) 23 | if '\n' in row['cmd']: 24 | tbl['cmd'] = tomlkit.string(row['cmd'], multiline=True) 25 | if '\n' in row['doc']: 26 | tbl['doc'] = tomlkit.string(row['doc'], multiline=True) 27 | items.append(tbl) 28 | document.add('snippets', items) 29 | 30 | with open(path, 'w') as fp: 31 | fp.write(document.as_string()) 32 | 33 | elapsed_time = time.time() - start_time 34 | logger.info(f'Exported {num_rows:n} snippets in {elapsed_time:.1f} seconds.', extra={'color': 'success'}) 35 | return super().export(path) 36 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // https://go.microsoft.com/fwlink/?linkid=830387 2 | { 3 | "version": "0.2.0", 4 | // https://code.visualstudio.com/docs/editor/variables-reference#_input-variables 5 | "inputs": [ 6 | { 7 | "id": "cliArgs", 8 | "type": "promptString", 9 | "description": "CLI arguments", 10 | } 11 | ], 12 | "configurations": [ 13 | { 14 | "name": "Debug current file", 15 | "type": "python", 16 | "request": "launch", 17 | "program": "${file}", 18 | "justMyCode": false, 19 | }, 20 | { 21 | "name": "Attach to process", 22 | "type": "python", 23 | "request": "attach", 24 | "processId": "${command:pickProcess}", 25 | "justMyCode": false, 26 | }, 27 | { 28 | "name": "Debug TUI", 29 | "type": "python", 30 | "request": "launch", 31 | "module": "clisnips", 32 | "args": [ 33 | "--log-level", 34 | "debug", 35 | ], 36 | "justMyCode": false, 37 | }, 38 | { 39 | "name": "Debug CLI", 40 | "type": "python", 41 | "request": "launch", 42 | "module": "clisnips", 43 | "args": "${input:cliArgs}", 44 | "justMyCode": false, 45 | } 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /clisnips/tui/components/syntax_error_dialog.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from textwrap import dedent 3 | 4 | import urwid 5 | 6 | from clisnips.tui.widgets.dialog import Dialog, ResponseKind 7 | 8 | _MESSAGE = dedent( 9 | """ 10 | There seems to be a syntax error in your snippet... 11 | You should probably edit it. 12 | """ 13 | ) 14 | 15 | 16 | class SyntaxErrorDialog(Dialog): 17 | def __init__(self, parent): 18 | text = urwid.Text(('warning', _MESSAGE)) 19 | body = urwid.Filler(urwid.Padding(text, width='pack', align='center'), valign='middle') 20 | 21 | super().__init__(parent, body) 22 | self.set_actions( 23 | Dialog.Action('Edit', ResponseKind.ACCEPT, Dialog.Action.Kind.SUGGESTED), 24 | Dialog.Action('Cancel', ResponseKind.REJECT), 25 | ) 26 | self._frame.focus_position = 1 27 | urwid.connect_signal(self, Dialog.Signals.RESPONSE, lambda *x: self.close()) 28 | 29 | def on_accept(self, callback: Callable[..., None], *args): 30 | def handler(dialog, response_type): 31 | if response_type == ResponseKind.ACCEPT: 32 | callback(*args) 33 | 34 | urwid.connect_signal(self, Dialog.Signals.RESPONSE, handler) 35 | -------------------------------------------------------------------------------- /clisnips/cli/commands/dump.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import gzip 3 | import logging 4 | import time 5 | from pathlib import Path 6 | from typing import TextIO 7 | 8 | from clisnips.cli.command import Command 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | def configure(cmd: argparse.ArgumentParser): 14 | cmd.add_argument('-c', '--compress', action='store_true', help='Compresses the output using gzip.') 15 | cmd.add_argument('file', type=Path) 16 | 17 | return DumpCommand 18 | 19 | 20 | class DumpCommand(Command): 21 | def run(self, argv) -> int: 22 | start_time = time.time() 23 | logger.info(f'Dumping database to {argv.file}') 24 | 25 | if argv.compress: 26 | with gzip.open(argv.file, 'wt') as fp: 27 | self._dump(fp) # type: ignore (the `wt` mode implies TextIO) 28 | else: 29 | with open(argv.file, 'w') as fp: 30 | self._dump(fp) 31 | 32 | elapsed = time.time() - start_time 33 | logger.info(f'Done in {elapsed:.1f} seconds.', extra={'color': 'success'}) 34 | return 0 35 | 36 | def _dump(self, fp: TextIO): 37 | db = self.container.database 38 | for line in db.connection.iterdump(): 39 | fp.write(f'{line}\n') 40 | -------------------------------------------------------------------------------- /clisnips/log/cli.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from typing import IO 4 | 5 | from clisnips.cli.utils import UrwidMarkupHelper 6 | 7 | 8 | def configure(helper: UrwidMarkupHelper, level: str, stream: IO = sys.stderr): 9 | handler = logging.StreamHandler(stream) 10 | if stream.isatty(): 11 | handler.formatter = UrwidRecordFormatter(helper) 12 | logging.basicConfig(level=level.upper(), handlers=(handler,)) 13 | 14 | 15 | class UrwidRecordFormatter(logging.Formatter): 16 | levels = { 17 | 'DEBUG': 'debug', 18 | 'INFO': 'info', 19 | 'SUCCESS': 'success', 20 | 'WARN': 'warning', 21 | 'WARNING': 'warning', 22 | 'ERROR': 'error', 23 | 'CRITICAL': 'error', 24 | } 25 | 26 | def __init__(self, helper: UrwidMarkupHelper) -> None: 27 | self._helper = helper 28 | super().__init__() 29 | 30 | def formatMessage(self, record: logging.LogRecord): 31 | spec = self.levels.get(record.levelname, 'info') 32 | markup = [ 33 | (spec, record.levelname), 34 | ('default', ': '), 35 | (getattr(record, 'color', spec), record.message), 36 | ('default', ''), 37 | ] 38 | return self._helper.convert_markup(markup, tty=True) 39 | -------------------------------------------------------------------------------- /tests/layouts/table_test.py: -------------------------------------------------------------------------------- 1 | from clisnips.tui.layouts.table import LayoutColumn, LayoutRow, TableLayout 2 | 3 | 4 | def render_cell(col: LayoutColumn, value: str) -> str: 5 | ps = ' ' * col.padding.start 6 | pe = ' ' * col.padding.end 7 | cell = value.ljust(col.computed_width - len(col.padding)) 8 | return f'{ps}{cell}{pe}' 9 | 10 | 11 | def render_row(row: LayoutRow) -> str: 12 | cells = [] 13 | for col, data in row: 14 | cells.append(render_cell(col, str(data))) 15 | inner = '|'.join(cells) 16 | return f'|{inner}|' 17 | 18 | 19 | def test_layout(): 20 | table: TableLayout[dict[str, str]] = TableLayout() 21 | padding = (1, 1) 22 | table.append_column(LayoutColumn('a', padding=padding)) 23 | table.append_column(LayoutColumn('b', padding=padding)) 24 | table.append_column(LayoutColumn('c', padding=padding)) 25 | 26 | rows = [ 27 | {'a': 'one', 'b': 'two', 'c': 'three'}, 28 | {'a': 'four', 'b': 'five', 'c': 'six'}, 29 | ] 30 | expected = """ 31 | | one | two | three | 32 | | four | five | six | 33 | """ 34 | table.layout(rows, 0) 35 | result = [] 36 | for row in table: 37 | result.append(render_row(row)) 38 | result = '\n'.join(result) 39 | assert result == expected.strip() 40 | -------------------------------------------------------------------------------- /clisnips/tui/widgets/dialogs/confirm.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | 3 | import urwid 4 | 5 | from clisnips.tui.widgets.dialog import Dialog, ResponseKind 6 | 7 | 8 | class ConfirmDialog(Dialog): 9 | def __init__(self, parent, message: str = '', accept_text: str = 'Confirm', reject_text: str = 'Cancel'): 10 | text = urwid.Text(message) 11 | body = urwid.Filler(urwid.Padding(text, width='pack', align='center'), valign='middle') 12 | super().__init__(parent, body) 13 | self.set_actions( 14 | Dialog.Action(reject_text, ResponseKind.REJECT), 15 | Dialog.Action(accept_text, ResponseKind.ACCEPT), 16 | ) 17 | self._frame.focus_position = 1 18 | urwid.connect_signal(self, Dialog.Signals.RESPONSE, lambda *x: self.close()) 19 | 20 | def on_accept(self, callback: Callable, *args): 21 | def handler(dialog, response_type): 22 | if response_type == ResponseKind.ACCEPT: 23 | callback(*args) 24 | 25 | urwid.connect_signal(self, Dialog.Signals.RESPONSE, handler) 26 | 27 | def on_reject(self, callback: Callable, *args): 28 | def handler(dialog, response_type): 29 | if response_type == ResponseKind.REJECT: 30 | callback(*args) 31 | 32 | urwid.connect_signal(self, Dialog.Signals.RESPONSE, handler) 33 | -------------------------------------------------------------------------------- /clisnips/tui/components/search_input.py: -------------------------------------------------------------------------------- 1 | import urwid 2 | 3 | from clisnips.stores.snippets import QueryState, SnippetsStore 4 | from clisnips.tui.widgets.edit import EmacsEdit 5 | from clisnips.tui.widgets.utils import suspend_emitter 6 | 7 | 8 | class SearchInput(EmacsEdit): 9 | def __init__(self, store: SnippetsStore): 10 | super().__init__(multiline=False) 11 | self._watchers = { 12 | 'value': store.watch(lambda s: s['search_query'], self._on_query_changed, immediate=True), 13 | 'state': store.watch(lambda s: s['query_state'], self._on_query_state_changed, immediate=True), 14 | } 15 | urwid.connect_signal(self, 'postchange', self._on_input_changed, weak_args=(store,)) 16 | 17 | def _on_query_changed(self, value: str): 18 | with suspend_emitter(self): 19 | self.set_edit_text(value) 20 | 21 | def _on_query_state_changed(self, state: QueryState): 22 | match state: 23 | case QueryState.VALID: 24 | self.set_caption(('search-entry:caption', '?> ')) 25 | case QueryState.INVALID: 26 | self.set_caption(('error', '!> ')) 27 | 28 | def _on_input_changed(self, store: SnippetsStore, *_): 29 | store.change_search_query(self.get_edit_text()) 30 | 31 | def get_search_text(self): 32 | return self.get_edit_text() 33 | -------------------------------------------------------------------------------- /clisnips/tui/loop.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import functools 3 | from asyncio import Handle, TimerHandle 4 | from collections.abc import Callable 5 | from typing import Any 6 | 7 | import urwid 8 | 9 | __loop = asyncio.get_event_loop() 10 | __uloop = urwid.AsyncioEventLoop(loop=__loop) 11 | 12 | 13 | def get_event_loop(): 14 | return __uloop 15 | 16 | 17 | def set_timeout(timeout: int | float, callback: Callable, *args) -> TimerHandle: 18 | if not args: 19 | return __uloop.alarm(timeout / 1000, callback) 20 | return __uloop.alarm(timeout / 1000, lambda: callback(*args)) 21 | 22 | 23 | def clear_timeout(handle: TimerHandle): 24 | __uloop.remove_alarm(handle) 25 | 26 | 27 | def idle_add(callback: Callable, *args) -> Handle: 28 | return __loop.call_soon_threadsafe(callback, *args) 29 | 30 | 31 | def debounce(fn: Callable[..., Any], delay: int = 300): 32 | handle = None 33 | 34 | @functools.wraps(fn) 35 | def wrapper(*args, **kwargs): 36 | nonlocal handle 37 | if handle: 38 | clear_timeout(handle) 39 | 40 | def handler(): 41 | nonlocal handle 42 | handle = None 43 | fn(*args, **kwargs) 44 | 45 | handle = set_timeout(delay, handler) 46 | 47 | return wrapper 48 | 49 | 50 | def debounced(delay: int = 300): 51 | return functools.partial(debounce, delay=delay) 52 | -------------------------------------------------------------------------------- /tests/syntax/documentation/executor_test.py: -------------------------------------------------------------------------------- 1 | from types import CodeType 2 | from clisnips.syntax.documentation.executor import Executor 3 | from clisnips.syntax.documentation.nodes import CodeBlock, Documentation 4 | 5 | 6 | def _make_doc_stub(*blocks: str): 7 | return Documentation( 8 | header='', 9 | parameters={}, 10 | code_blocks=[CodeBlock(c) for c in blocks], 11 | ) 12 | 13 | 14 | def test_compile_str(): 15 | result = Executor.compile_str("print('Hello')") 16 | assert isinstance(result, CodeType) 17 | 18 | 19 | def test_execute_single(): 20 | code = """ 21 | import os.path 22 | if fields['infile'] and not fields['outfile']: 23 | path, ext = os.path.splitext(fields['infile']) 24 | fields['outfile'] = path + '.mp4' 25 | """ 26 | doc = _make_doc_stub(code) 27 | # execute code 28 | ctx = { 29 | 'fields': { 30 | 'infile': '/foo/bar.wav', 31 | 'outfile': '', 32 | }, 33 | } 34 | result = Executor(doc).execute(ctx) 35 | assert result['fields']['outfile'] == '/foo/bar.mp4' 36 | 37 | 38 | def test_execute_multiple(): 39 | doc = _make_doc_stub( 40 | r"""test['x'] = 42""", 41 | r"""test['x'] *= 2""", 42 | r"""test['x'] = f'19{test["x"]}'""", 43 | r"""by = 'Orwell'""", 44 | ) 45 | result = Executor(doc).execute({'test': {}}) 46 | assert result['test']['x'] == '1984' 47 | assert result['by'] == 'Orwell' 48 | -------------------------------------------------------------------------------- /clisnips/tui/app.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | from collections.abc import Hashable 3 | 4 | from clisnips.config.state import save_persistent_state 5 | from clisnips.dic import DependencyInjectionContainer 6 | 7 | from .tui import TUI 8 | from .views.snippets_list import SnippetListView 9 | 10 | 11 | class Application: 12 | def __init__(self, dic: DependencyInjectionContainer): 13 | self.container = dic 14 | self.current_view = None 15 | self.ui = TUI(dic.config.palette) 16 | self.ui.register_view('snippets-list', self._build_snippets_list) 17 | atexit.register(self._on_exit) 18 | 19 | def run(self) -> int: 20 | self.activate_view('snippets-list') 21 | self.ui.main() 22 | return 0 23 | 24 | def activate_view(self, name: Hashable, **kwargs): 25 | self.current_view = self.ui.build_view(name, display=True, **kwargs) 26 | self.ui.refresh() 27 | 28 | def _build_snippets_list(self, *args, **kwargs): 29 | view = SnippetListView(self.container.snippets_store) 30 | self.ui.connect(view, SnippetListView.Signals.APPLY_SNIPPET_REQUESTED, self._on_apply_snippet_requested) 31 | return view 32 | 33 | def _on_apply_snippet_requested(self, view: SnippetListView, command: str): 34 | self.ui.exit_with_message(command) 35 | 36 | def _on_exit(self): 37 | save_persistent_state(self.container.snippets_store.state) 38 | self.container.database.close() 39 | -------------------------------------------------------------------------------- /clisnips/tui/widgets/spinner.py: -------------------------------------------------------------------------------- 1 | import urwid 2 | 3 | from clisnips.tui.animation import AnimationController 4 | 5 | 6 | class Spinner(urwid.Widget): 7 | _frames = ('⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷') 8 | 9 | _sizing = frozenset([urwid.FIXED]) 10 | 11 | def __init__(self): 12 | self._canvas_cache = [] 13 | self._current_frame = 0 14 | self._animation = AnimationController(self._update, 12) 15 | for i, frame in enumerate(self._frames): 16 | canvas = urwid.Text(self._frames[i]).render((3,)) 17 | self._canvas_cache.append(canvas) 18 | 19 | def toggle(self): 20 | self._animation.toggle() 21 | 22 | def start(self): 23 | self._current_frame = 0 24 | self._animation.start() 25 | 26 | def stop(self): 27 | self._animation.stop() 28 | 29 | def render(self, size, focus=False): 30 | canvas = self._canvas_cache[self._current_frame] 31 | c = urwid.CompositeCanvas(canvas) 32 | pad_h = int((size[0] - 3) / 2) 33 | pad_v = int((size[1] - 1) / 2) 34 | c.pad_trim_left_right(pad_h, pad_h) 35 | c.pad_trim_top_bottom(pad_v, pad_v) 36 | return c 37 | 38 | def rows(self, size, focus=False): 39 | return 1 40 | 41 | def pack(self, size=None, focus=False): 42 | return 3, 1 43 | 44 | def _update(self, delta): 45 | self._current_frame = int(self._current_frame + delta) % len(self._frames) 46 | self._invalidate() 47 | -------------------------------------------------------------------------------- /clisnips/cli/commands/export.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | from pathlib import Path 4 | 5 | from clisnips.cli.command import Command 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | def configure(cmd: argparse.ArgumentParser): 11 | cmd.add_argument('-f', '--format', choices=('xml', 'json', 'toml'), default=None) 12 | cmd.add_argument('file', type=Path) 13 | 14 | return ExportCommand 15 | 16 | 17 | class ExportCommand(Command): 18 | def run(self, argv) -> int: 19 | cls = self._get_exporter_class(argv.format, argv.file.suffix) 20 | if not cls: 21 | logger.error( 22 | f'Could not detect export format for {argv.file}.\n' 23 | 'Please provide an explicit format with the --format option.' 24 | ) 25 | return 1 26 | 27 | cls(self.container.database).export(argv.file) 28 | return 0 29 | 30 | def _get_exporter_class(self, format: str | None, suffix: str): 31 | match format, suffix: 32 | case ('json', _) | (None, '.json'): 33 | from clisnips.exporters import JsonExporter 34 | 35 | return JsonExporter 36 | case ('toml', _) | (None, '.toml'): 37 | from clisnips.exporters import TomlExporter 38 | 39 | return TomlExporter 40 | case ('xml', _) | (None, '.xml'): 41 | from clisnips.exporters import XmlExporter 42 | 43 | return XmlExporter 44 | case _: 45 | return None 46 | -------------------------------------------------------------------------------- /clisnips/tui/widgets/field/field.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, TypeVar 2 | import urwid 3 | 4 | from clisnips.tui.urwid_types import TextMarkup 5 | 6 | 7 | V = TypeVar('V') 8 | 9 | 10 | class Field(Generic[V]): 11 | signals = ['changed'] 12 | 13 | def get_value(self) -> V: 14 | raise NotImplementedError() 15 | 16 | def set_validation_markup(self, value: TextMarkup): 17 | raise NotImplementedError() 18 | 19 | 20 | class Entry(Generic[V]): 21 | signals = ['changed'] 22 | 23 | def get_value(self) -> V: 24 | raise NotImplementedError() 25 | 26 | 27 | class SimpleField(Field[V], urwid.Pile): 28 | _VALIDATION_POSITION = 2 29 | 30 | def __init__(self, label: TextMarkup, entry: Entry[V]): 31 | self._entry = entry 32 | urwid.connect_signal(self._entry, 'changed', lambda *w: self._emit('changed')) 33 | self._validation_text = urwid.Text('') 34 | super().__init__( 35 | [ 36 | urwid.Text(label), 37 | self._entry, # type: ignore 38 | # self._validation_text, 39 | ] 40 | ) 41 | 42 | def get_value(self) -> V: 43 | return self._entry.get_value() 44 | 45 | def set_validation_markup(self, value: TextMarkup): 46 | self._validation_text.set_text(value) 47 | try: 48 | self.contents.remove((self._validation_text, ('weight', 1))) 49 | except ValueError: 50 | ... 51 | if value: 52 | self.contents.insert(self._VALIDATION_POSITION, (self._validation_text, ('weight', 1))) 53 | -------------------------------------------------------------------------------- /clisnips/utils/number.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | import re 3 | from typing import Any 4 | 5 | # We rely on the fact that str(float(n)) will always normalize the number representation 6 | FLOAT_RE = re.compile( 7 | r""" 8 | ^ 9 | [-]? \d+ 10 | (?:\. (?P \d+ ))? 11 | (?:e- (?P \d+ ))? 12 | $ 13 | """, 14 | re.X, 15 | ) 16 | 17 | 18 | def get_num_decimals(n: Any) -> int: 19 | try: 20 | n = float(n) 21 | except ValueError: 22 | return 0 23 | if n.is_integer(): 24 | return 0 25 | d = 0 26 | match = FLOAT_RE.match(str(n)) 27 | assert match 28 | if n := match['decimals']: 29 | d += len(n) 30 | if n := match['exponent']: 31 | d += int(n) 32 | return d 33 | 34 | 35 | def is_integer_decimal(value: Decimal) -> bool: 36 | return value == value.to_integral_value() 37 | 38 | 39 | def get_default_range_step(start: Decimal, end: Decimal) -> Decimal: 40 | start_decimals = get_num_decimals(start) 41 | end_decimals = get_num_decimals(end) 42 | if start_decimals == 0 and end_decimals == 0: 43 | return Decimal('1') 44 | n = max(start_decimals, end_decimals) 45 | return Decimal('0.{pad}1'.format(pad='0' * (n - 1))) 46 | 47 | 48 | def clamp_to_range(value: Decimal, start: Decimal, end: Decimal) -> Decimal: 49 | """ 50 | Clamps `value` to the range [start:end], allowing for reversed ranges 51 | (where start > end) 52 | """ 53 | if start > end: 54 | start, end = end, start 55 | return max(start, min(end, value)) 56 | -------------------------------------------------------------------------------- /clisnips/config/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | from clisnips.ty import AnyPath 5 | 6 | from .envs import DB_PATH 7 | from .palette import Palette 8 | from .paths import get_config_path, get_runtime_path 9 | from .settings import AppSettings 10 | 11 | SCHEMA_BASE_URI = 'https://raw.githubusercontent.com/ju1ius/clisnips/master/schemas' 12 | 13 | 14 | class Config: 15 | def __init__(self): 16 | self._cfg = _load_settings() 17 | 18 | @property 19 | def database_path(self) -> AnyPath: 20 | if DB_PATH is not None: 21 | return DB_PATH 22 | path = self._cfg.database 23 | if path == ':memory:': 24 | return path 25 | return Path(path).expanduser().absolute() 26 | 27 | @property 28 | def palette(self) -> Palette: 29 | return self._cfg.palette.resolved() 30 | 31 | @property 32 | def log_file(self) -> Path: 33 | return get_runtime_path('logs.sock') 34 | 35 | def write(self, fp): 36 | data = { 37 | '$schema': f'{SCHEMA_BASE_URI}/settings.json', 38 | 'database': str(self.database_path), 39 | 'palette': self._cfg.palette.model_dump(), 40 | } 41 | json.dump(data, fp, indent=2) 42 | 43 | 44 | def _load_settings() -> AppSettings: 45 | match get_config_path('settings.json'): 46 | case p if p.exists(): 47 | with open(p) as fp: 48 | settings = AppSettings.model_validate_json(fp.read(), strict=True) 49 | case _: 50 | settings = AppSettings() 51 | return settings 52 | -------------------------------------------------------------------------------- /clisnips/syntax/command/nodes.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable 2 | from dataclasses import dataclass 3 | from typing import TypeAlias 4 | 5 | 6 | @dataclass(slots=True, frozen=True) 7 | class Text: 8 | value: str 9 | start: int 10 | end: int 11 | 12 | def __str__(self): 13 | return self.value 14 | 15 | 16 | @dataclass(slots=True, frozen=True) 17 | class Field: 18 | name: str 19 | start: int 20 | end: int 21 | format_spec: str | None = '' 22 | conversion: str | None = None 23 | 24 | def __str__(self): 25 | conv = f'!{self.conversion}' if self.conversion else '' 26 | spec = f':{self.format_spec}' if self.format_spec else '' 27 | return f'{{{self.name}{conv}{spec}}}' 28 | 29 | 30 | Node: TypeAlias = Field | Text 31 | 32 | 33 | class CommandTemplate: 34 | def __init__(self, raw: str, nodes: list[Node]): 35 | self.raw = raw 36 | self.nodes = nodes 37 | 38 | @property 39 | def text(self) -> str: 40 | return ''.join(n.value for n in self.nodes if isinstance(n, Text)) 41 | 42 | @property 43 | def fields(self) -> Iterable[Field]: 44 | return (n for n in self.nodes if isinstance(n, Field)) 45 | 46 | def has_fields(self) -> bool: 47 | return any(self.fields) 48 | 49 | @property 50 | def field_names(self) -> Iterable[str]: 51 | return (f.name for f in self.fields) 52 | 53 | def __eq__(self, other): 54 | return other.raw == self.raw and other.nodes == self.nodes 55 | 56 | def __str__(self): 57 | return ''.join(str(n) for n in self.nodes) 58 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["poetry>=1.7"] 3 | build-backend = "poetry.masonry.api" 4 | 5 | [tool.poetry] 6 | name = "clisnips" 7 | version = "0.6.0" 8 | description = "A command-line snippets manager." 9 | authors = ["ju1ius "] 10 | license = "GPL-3.0+" 11 | readme = "README.md" 12 | repository = "https://github.com/ju1ius/clisnips" 13 | keywords = ["snippet", "snippets", "cli", "tui"] 14 | classifiers = [ 15 | 'Development Status :: 3 - Alpha', 16 | 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', 17 | 'Environment :: Console', 18 | 'Environment :: Console :: Curses', 19 | 'Operating System :: Unix', 20 | 'Operating System :: POSIX', 21 | 'Operating System :: Microsoft :: Windows', 22 | 'Programming Language :: Python', 23 | 'Programming Language :: Python :: 3.11', 24 | 'Programming Language :: Python :: Implementation :: CPython', 25 | 'Topic :: Terminals', 26 | 'Topic :: Terminals :: Terminal Emulators/X Terminals', 27 | 'Topic :: System :: System Shells', 28 | 'Topic :: Utilities', 29 | ] 30 | 31 | [tool.poetry.scripts] 32 | clisnips = 'clisnips.__main__:main' 33 | 34 | [tool.poetry.dependencies] 35 | python = "^3.11" 36 | urwid = "^2.2.3" 37 | pygments = "^2.16.1" 38 | observ = "^0.14" 39 | pydantic = "^2.4.2" 40 | tomlkit = "^0.12.3" 41 | typing-extensions = "^4.8.0" 42 | 43 | [tool.poetry.group.dev.dependencies] 44 | pytest = "^7.4.3" 45 | pyfakefs = "^5.3.0" 46 | ruff = "^0.1.6" 47 | 48 | [tool.ruff] 49 | target-version = "py311" 50 | line-length = 120 51 | 52 | [tool.ruff.format] 53 | quote-style = "single" 54 | 55 | [tool.ruff.lint] 56 | extend-select = [ 57 | 'UP', # pyupgrade 58 | ] 59 | -------------------------------------------------------------------------------- /clisnips/config/paths.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | from clisnips.ty import AnyPath 5 | 6 | _APP = 'clisnips' 7 | 8 | 9 | def xdg_config_home() -> Path: 10 | match os.environ.get('XDG_CONFIG_HOME'): 11 | case None | '': 12 | return Path('~/.config').expanduser() 13 | case v: 14 | return Path(v) 15 | 16 | 17 | def xdg_data_home() -> Path: 18 | match os.environ.get('XDG_DATA_HOME'): 19 | case None | '': 20 | return Path('~/.local/share').expanduser() 21 | case v: 22 | return Path(v) 23 | 24 | 25 | def xdg_state_home() -> Path: 26 | match os.environ.get('XDG_STATE_HOME'): 27 | case None | '': 28 | return Path('~/.local/state').expanduser() 29 | case v: 30 | return Path(v) 31 | 32 | 33 | def xdg_runtime_dir() -> Path: 34 | match os.environ.get('XDG_RUNTIME_DIR'): 35 | case None | '': 36 | return xdg_state_home() 37 | case v: 38 | return Path(v) 39 | 40 | 41 | def get_config_path(sub: AnyPath) -> Path: 42 | return xdg_config_home() / _APP / sub 43 | 44 | 45 | def get_data_path(sub: AnyPath) -> Path: 46 | return xdg_data_home() / _APP / sub 47 | 48 | 49 | def get_state_path(sub: AnyPath) -> Path: 50 | return xdg_state_home() / _APP / sub 51 | 52 | 53 | def get_runtime_path(sub: AnyPath) -> Path: 54 | return xdg_runtime_dir() / _APP / sub 55 | 56 | 57 | def ensure_app_dirs(): 58 | for d in ( 59 | get_config_path(''), 60 | get_data_path(''), 61 | get_state_path(''), 62 | get_runtime_path(''), 63 | ): 64 | d.mkdir(parents=True, exist_ok=True) 65 | -------------------------------------------------------------------------------- /clisnips/cli/commands/install_key_bindings.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | from importlib import resources 4 | from pathlib import Path 5 | 6 | from clisnips.cli.command import Command 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | def configure(cmd: argparse.ArgumentParser): 12 | cmd.add_argument('shell', choices=['bash', 'zsh']) 13 | 14 | return InstallBindingsCommand 15 | 16 | 17 | class InstallBindingsCommand(Command): 18 | shell_rcs = { 19 | 'bash': '~/.bashrc', 20 | 'zsh': '~/.zshrc', 21 | } 22 | 23 | def run(self, argv) -> int: 24 | shell = argv.shell 25 | src = resources.files('clisnips.resources').joinpath(f'key-bindings.{shell}') 26 | dest = f'~/.clisnips.{shell}' 27 | dest_path = Path(dest).expanduser() 28 | rc_file = Path(self.shell_rcs[shell]).expanduser() 29 | 30 | logger.info(f'Installing key bindings for {shell} in {dest_path}') 31 | with src.open() as res: 32 | with open(dest_path, 'w') as fp: 33 | fp.write(res.read()) 34 | 35 | logger.info(f'Updating {rc_file}') 36 | with open(rc_file, mode='a') as fp: 37 | fp.writelines( 38 | ( 39 | '# clisnips key bindings', 40 | f'[ -f {dest} ] && source {dest}', 41 | ) 42 | ) 43 | 44 | logger.info('OK', extra={'color': 'success'}) 45 | self.print( 46 | ('info', 'To use the new key bindings, either open a new shell or run:'), 47 | ('default', f'source {rc_file}'), 48 | sep='\n', 49 | stderr=True, 50 | ) 51 | 52 | return 0 53 | -------------------------------------------------------------------------------- /clisnips/config/state.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | from typing import Any, TypedDict 5 | 6 | from clisnips.database import SortColumn, SortOrder 7 | from clisnips.stores.snippets import ListLayout, State 8 | 9 | from .paths import get_state_path 10 | 11 | 12 | class PersistentState(TypedDict): 13 | page_size: int 14 | sort_by: SortColumn 15 | sort_order: SortOrder 16 | list_layout: ListLayout 17 | 18 | 19 | DEFAULTS: PersistentState = { 20 | 'page_size': 25, 21 | 'sort_by': SortColumn.RANKING, 22 | 'sort_order': SortOrder.ASC, 23 | 'list_layout': ListLayout.LIST, 24 | } 25 | 26 | 27 | def load_persistent_state() -> PersistentState: 28 | try: 29 | with open(get_state_path('state.json')) as fp: 30 | data = json.load(fp) 31 | return _merge_defaults(data) 32 | except FileNotFoundError: 33 | return DEFAULTS.copy() 34 | 35 | 36 | def save_persistent_state(state: State): 37 | s: PersistentState = { 38 | 'page_size': state['page_size'], 39 | 'sort_by': state['sort_by'], 40 | 'sort_order': state['sort_order'], 41 | 'list_layout': state['list_layout'], 42 | } 43 | with open(get_state_path('state.json'), 'w') as fp: 44 | json.dump(s, fp, indent=2) 45 | 46 | 47 | def _merge_defaults(data: dict[Any, Any]) -> PersistentState: 48 | result = DEFAULTS.copy() 49 | if v := data.get('page_size'): 50 | result['page_size'] = int(v) 51 | if v := data.get('sort_by'): 52 | result['sort_by'] = SortColumn(v) 53 | if v := data.get('sort_order'): 54 | result['sort_order'] = SortOrder(v) 55 | if v := data.get('list_layout'): 56 | result['list_layout'] = ListLayout(v) 57 | return result 58 | -------------------------------------------------------------------------------- /clisnips/tui/widgets/radio.py: -------------------------------------------------------------------------------- 1 | import enum 2 | from collections.abc import Hashable, Iterator 3 | from typing import Generic, Literal, Self, TypeVar 4 | 5 | import urwid 6 | 7 | V = TypeVar('V', bound=Hashable) 8 | RadioState = bool | Literal['mixed'] 9 | 10 | 11 | class RadioItem(urwid.RadioButton, Generic[V]): 12 | def __init__(self, group: list[Self], label: str, value: V, selected: bool = False): 13 | super().__init__( 14 | group, # type: ignore (python type system sucks) 15 | label, 16 | state=selected, 17 | ) 18 | self._value = value 19 | 20 | def get_value(self) -> V: 21 | return self._value 22 | 23 | 24 | class RadioGroup(Generic[V]): 25 | class Signals(enum.StrEnum): 26 | CHANGED = 'changed' 27 | 28 | def __init__(self, choices: dict[V, str]): 29 | self._group: list[RadioItem[V]] = [] 30 | for value, label in choices.items(): 31 | item = RadioItem(self._group, label, value) 32 | urwid.connect_signal(item, 'change', self._on_item_clicked) 33 | 34 | def get_value(self) -> V | None: 35 | for item in self._group: 36 | if item.get_state() is True: 37 | return item.get_value() 38 | return None 39 | 40 | def set_value(self, value: V): 41 | for item in self._group: 42 | item.set_state(value == item.get_value(), do_callback=False) 43 | 44 | def _on_item_clicked(self, item: RadioItem[V], state: RadioState): 45 | if state is True: 46 | urwid.emit_signal(self, self.Signals.CHANGED, item.get_value()) 47 | 48 | def __iter__(self) -> Iterator[RadioItem[V]]: 49 | return iter(self._group) 50 | 51 | 52 | urwid.register_signal(RadioGroup, list(RadioGroup.Signals)) 53 | -------------------------------------------------------------------------------- /clisnips/tui/components/help_dialog.py: -------------------------------------------------------------------------------- 1 | import urwid 2 | 3 | from clisnips.tui.widgets.dialog import Dialog, ResponseKind 4 | 5 | SHORTCUTS = ( 6 | ('F1', 'Help'), 7 | ('F2', 'Snippet list options'), 8 | ('q', 'Quit'), 9 | ('ESC', 'Quit or close current dialog.'), 10 | ('/', 'Focus search input'), 11 | ('Tab, Shift+Tab', 'Cycle focus between search input and snippet list.'), 12 | ('UP, DOWN', 'Navigate between fields.'), 13 | ('ENTER', 'Insert selected snippet in terminal.'), 14 | ('n', 'Go to next result page'), 15 | ('p', 'Go to previous result page'), 16 | ('f', 'Go to first result page'), 17 | ('l', 'Go to last result page'), 18 | ('e', 'Edit selected snippet'), 19 | ('+, i, INS', 'Create new snippet'), 20 | ('-, d, DEL', 'Delete selected snippet'), 21 | ('s', 'Show details for selected snippet'), 22 | ) 23 | 24 | 25 | def _get_key_column_width(): 26 | key, desc = max(SHORTCUTS, key=lambda x: len(x[0])) 27 | return len(key) 28 | 29 | 30 | class HelpDialog(Dialog): 31 | def __init__(self, parent): 32 | body = [] 33 | key_width = _get_key_column_width() 34 | for key, desc in SHORTCUTS: 35 | cols = urwid.Columns( 36 | [ 37 | (key_width, urwid.Text(('help:key', key))), 38 | ('pack', urwid.Text(desc)), 39 | ], 40 | 1, 41 | ) 42 | body.append(('pack', cols)) 43 | 44 | body = urwid.Pile(body, 0) 45 | 46 | super().__init__(parent, body) 47 | self.set_actions( 48 | Dialog.Action('OK', ResponseKind.ACCEPT, Dialog.Action.Kind.SUGGESTED), 49 | ) 50 | self._frame.focus_position = 1 51 | urwid.connect_signal(self, Dialog.Signals.RESPONSE, lambda *x: self.close()) 52 | -------------------------------------------------------------------------------- /snippets/quick-tour.toml: -------------------------------------------------------------------------------- 1 | # You can import this file in your snippets database by running: 2 | # clisnips import snippets/quick-tour.toml 3 | 4 | [[snippets]] 5 | # This is a minimal snippet. 6 | # Let's give it a nice readable description: 7 | title = "Daily backup of my documents folder" 8 | # Only the `title` and `tag` fields are included in the search index. 9 | # We can use the tag field to provide useful keywords: 10 | tag = "tar,gz" 11 | cmd = "tar cvzf /mnt/backups/documents_$(date -I).tar.gz ~/Documents" 12 | 13 | 14 | [[snippets]] 15 | # Let's make the previous snippet more reusable! 16 | title = "Daily backup of a folder" 17 | tag = "tar,gz" 18 | # Here we add two template fields: {archive} and {directory}. 19 | # When selecting this snippet in the TUI, we will be prompted 20 | # to provide a value for each field. 21 | cmd = "tar cvzf {archive}_$(date -I).tar.gz {directory}" 22 | 23 | 24 | [[snippets]] 25 | # Now let's improve our snippet by adding some documentation. 26 | title = "Creates a daily gzipped archive of a directory" 27 | tag = "tar,gz" 28 | cmd = "tar cvzf {archive}-$(date -I).tar.gz {directory}" 29 | # Following is the documentation for our snippet. 30 | # 31 | # Each line starting with a `{name}` documents the `name` field. 32 | # In this case, we add a `(path)` hint to the `{archive}` field, 33 | # and a `(dir)` hint to the `{directory}` field. 34 | # 35 | # The (path) hint is used for filesystem paths. 36 | # The (dir) hint is used for directories. 37 | # Both of these hints enable path completion in the UI by pressing . 38 | doc = """\ 39 | This is the documentation for our snippet. 40 | 41 | {archive} (path) Path to the archive 42 | {directory} (dir) Directory to backup 43 | """ 44 | 45 | # And that's about it for our quick tour! 46 | # For more, please check: https://github.com/ju1ius/clisnips/tree/master/docs/ 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # clisnips 2 | 3 | 4 | clisnips is a command-line snippets manager. 5 | 6 | ![xkcd/tar](http://imgs.xkcd.com/comics/tar.png) 7 | 8 | It provides a graphical command-line user interface in which you can save, search and recall your commands. 9 | 10 | 11 | ## Installation 12 | 13 | clisnips requires python 3.11 or higher. 14 | 15 | ### 1. Install clisnips 16 | 17 | The recommended way is to use [pipx](https://pypa.github.io/pipx/): 18 | ```sh 19 | pipx install clisnips 20 | ``` 21 | 22 | ### 2. Install shell key-bindings 23 | 24 | ```sh 25 | # For bash 26 | clisnips key-bindings bash 27 | # For zsh 28 | clisnips key-bindings zsh 29 | ``` 30 | 31 | Then: 32 | * Either open a *new* shell or source your shell rc file, 33 | * and type the `Alt+s` keyboard shortcut to open the snippets library. 34 | 35 | ## Usage 36 | 37 | Clisnips stores snippets in a local SQLite database, 38 | using an FTS5 table to enable full-text search. 39 | The search input accepts the whole [FTS5 full-text query syntax][fts5-ref]. 40 | 41 | Please have a look at [the docs][docs-folder] for getting started on 42 | [writing your own snippets][creating-snippets]. 43 | 44 | You may also read the [quick-tour][], 45 | a small TOML file containing some example snippets. 46 | You can import it in your snippets database by running: 47 | ```sh 48 | clisnips import snippets/quick-tour.toml 49 | ``` 50 | 51 | In addition to its TUI, clisnips comes with a bunch of other subcommands 52 | to help you manage your snippets. Please run `clisnips --help` to read the CLI documentation. 53 | 54 | 55 | [quick-tour]: https://github.com/ju1ius/clisnips/blob/master/snippets/quick-tour.toml 56 | [docs-folder]: https://github.com/ju1ius/clisnips/blob/master/docs/ 57 | [creating-snippets]: https://github.com/ju1ius/clisnips/blob/master/docs/creating-snippets.md 58 | [fts5-ref]: https://www.sqlite.org/fts5.html#full_text_query_syntax 59 | -------------------------------------------------------------------------------- /clisnips/tui/components/show_snippet_dialog.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import urwid 4 | 5 | from clisnips.database import Snippet 6 | from clisnips.tui.highlighters import highlight_command, highlight_documentation 7 | from clisnips.tui.urwid_types import TextMarkup 8 | from clisnips.tui.widgets.dialog import Dialog 9 | from clisnips.tui.widgets.divider import HorizontalDivider 10 | 11 | 12 | class ShowSnippetDialog(Dialog): 13 | def __init__(self, parent, snippet: Snippet): 14 | body = urwid.ListBox( 15 | urwid.SimpleListWalker( 16 | ( 17 | _field('Title:', ('snip:title', snippet['title'])), 18 | HorizontalDivider(), 19 | _field('Tags:', ('snip:tag', snippet['tag'])), 20 | HorizontalDivider(), 21 | _field('Command:', highlight_command(snippet['cmd'])), 22 | HorizontalDivider(), 23 | _field('Documentation:', highlight_documentation(snippet['doc'])), 24 | HorizontalDivider(), 25 | _info('Created on: ', _date(snippet['created_at'])), 26 | _info('Last used on: ', _date(snippet['last_used_at'])), 27 | _info('Usage count: ', snippet['usage_count']), 28 | _info('Ranking: ', snippet['ranking']), 29 | ), 30 | ), 31 | ) 32 | 33 | super().__init__(parent, body) 34 | 35 | 36 | def _field(label: TextMarkup, content: TextMarkup): 37 | field = urwid.Pile( 38 | ( 39 | urwid.Text(label), 40 | urwid.Text(content), 41 | ), 42 | ) 43 | return field 44 | 45 | 46 | def _info(label: str, value: str | float): 47 | return urwid.Text([label, ('info', str(value))]) 48 | 49 | 50 | def _date(timestamp: float) -> str: 51 | return time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime(timestamp)) 52 | -------------------------------------------------------------------------------- /clisnips/importers/xml.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from collections.abc import Iterable 4 | from pathlib import Path 5 | from textwrap import dedent 6 | from typing import TextIO 7 | from xml.etree import ElementTree 8 | 9 | from clisnips.database import ImportableSnippet 10 | 11 | from .base import Importer, SnippetAdapter 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class XmlImporter(Importer): 17 | def import_path(self, path: Path) -> None: 18 | start_time = time.time() 19 | logger.info(f'Importing snippets from {path}') 20 | 21 | with open(path) as fp: 22 | if self._dry_run: 23 | for _ in _parse_snippets(fp): 24 | ... 25 | else: 26 | self._db.insert_many(_parse_snippets(fp)) 27 | logger.info('Rebuilding & optimizing search index') 28 | if not self._dry_run: 29 | self._db.rebuild_index() 30 | self._db.optimize_index() 31 | 32 | elapsed_time = time.time() - start_time 33 | logger.info(f'Imported in {elapsed_time:.1f} seconds.', extra={'color': 'success'}) 34 | 35 | 36 | def _parse_snippets(file: TextIO) -> Iterable[ImportableSnippet]: 37 | now = int(time.time()) 38 | for _, el in ElementTree.iterparse(file): 39 | if el.tag != 'snippet': 40 | continue 41 | yield SnippetAdapter.validate_python( 42 | { 43 | 'title': el.findtext('title').strip(), 44 | 'tag': el.findtext('tag').strip(), 45 | 'cmd': dedent(el.findtext('command')), 46 | 'doc': dedent(el.findtext('doc').strip()), 47 | 'created_at': el.attrib.get('created-at', now), 48 | 'last_used_at': el.attrib.get('last-used-at', 0), 49 | 'usage_count': el.attrib.get('usage-count', 0), 50 | 'ranking': el.attrib.get('ranking', 0.0), 51 | } 52 | ) 53 | -------------------------------------------------------------------------------- /clisnips/exporters/xml.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from pathlib import Path 4 | from xml.dom.minidom import Document, Element 5 | 6 | from clisnips.database import Snippet 7 | from clisnips.exporters.base import Exporter 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class XmlExporter(Exporter): 13 | def export(self, path: Path): 14 | start_time = time.time() 15 | num_rows = len(self._db) 16 | logger.info(f'Converting {num_rows:n} snippets to XML') 17 | 18 | doc = Document() 19 | root = doc.createElement('snippets') 20 | for row in self._db: 21 | snip = _create_snippet(doc, row) 22 | root.appendChild(snip) 23 | doc.appendChild(root) 24 | xml = doc.toprettyxml(indent=' ') 25 | 26 | logger.debug(f'Writing snippets to {path} ...') 27 | with open(path, 'w') as fp: 28 | fp.write(xml) 29 | 30 | elapsed_time = time.time() - start_time 31 | logger.info(f'Exported {num_rows:n} snippets in {elapsed_time:.1f} seconds.', extra={'color': 'success'}) 32 | 33 | 34 | def _create_snippet(doc: Document, row: Snippet) -> Element: 35 | snip = doc.createElement('snippet') 36 | snip.setAttribute('created-at', str(row['created_at'])) 37 | snip.setAttribute('last-used-at', str(row['last_used_at'])) 38 | snip.setAttribute('usage-count', str(row['usage_count'])) 39 | snip.setAttribute('ranking', str(row['ranking'])) 40 | _add_field(doc, snip, 'title', row['title']) 41 | _add_field(doc, snip, 'command', row['cmd']) 42 | _add_field(doc, snip, 'tag', row['tag']) 43 | _add_field(doc, snip, 'doc', row['doc'], cdata=True) 44 | return snip 45 | 46 | 47 | def _add_field(doc: Document, parent: Element, name: str, text: str, cdata: bool = False): 48 | el = doc.createElement(name) 49 | if cdata: 50 | txt = doc.createCDATASection(text) 51 | else: 52 | txt = doc.createTextNode(text) 53 | el.appendChild(txt) 54 | parent.appendChild(el) 55 | -------------------------------------------------------------------------------- /clisnips/tui/widgets/field/__init__.py: -------------------------------------------------------------------------------- 1 | from clisnips.syntax.documentation.nodes import Documentation, Parameter, ValueList, ValueRange 2 | from clisnips.tui.urwid_types import TextMarkup 3 | 4 | from .field import Field 5 | from .flag import FlagField 6 | from .path import PathField 7 | from .range import RangeField 8 | from .select import SelectField 9 | from .text import TextField 10 | 11 | 12 | def field_from_documentation(field_name: str, doc: Documentation) -> Field: 13 | param = doc.parameters.get(field_name) 14 | if not param: 15 | label = ('bold', field_name) 16 | if field_name.startswith('-'): 17 | return FlagField(label, flag=field_name) 18 | return TextField(label) 19 | return _field_from_param(param) 20 | 21 | 22 | def _field_from_param(param: Parameter) -> Field: 23 | label = _label_from_param(param) 24 | type_hint = param.type_hint 25 | value_hint = param.value_hint 26 | default = '' 27 | if isinstance(value_hint, ValueRange): 28 | return RangeField( 29 | label, 30 | start=value_hint.start, 31 | end=value_hint.end, 32 | step=value_hint.step, 33 | default=value_hint.default, 34 | ) 35 | if isinstance(value_hint, ValueList): 36 | if len(value_hint) > 1: 37 | return SelectField(label, choices=value_hint.values, default=value_hint.default) 38 | default = str(value_hint.values[0]) 39 | if type_hint in ('path', 'file', 'dir'): 40 | return PathField(label, mode=type_hint, default=default) 41 | if type_hint == 'flag': 42 | return FlagField(label, flag=param.name) 43 | return TextField(label, default=default) 44 | 45 | 46 | def _label_from_param(param: Parameter) -> TextMarkup: 47 | markup: TextMarkup = [('syn:doc:parameter', param.name)] 48 | if param.type_hint: 49 | markup.append(('syn:doc:type-hint', f' ({param.type_hint})')) 50 | if param.text: 51 | markup.append(('syn:doc:default', f' {param.text.strip()}')) 52 | return markup 53 | -------------------------------------------------------------------------------- /tests/utils/number_test.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | import pytest 3 | from clisnips.utils.number import clamp_to_range, get_default_range_step, get_num_decimals, is_integer_decimal 4 | 5 | 6 | @pytest.mark.parametrize( 7 | ('value', 'expected'), 8 | ( 9 | (42, 0), 10 | (0.25, 2), 11 | (1.255, 3), 12 | (0.0169, 4), 13 | ('0.25', 2), 14 | ('1.25e-3', 5), 15 | (Decimal('0.25'), 2), 16 | (Decimal('1.25e-3'), 5), 17 | ), 18 | ) 19 | def test_num_decimals(value, expected: int): 20 | result = get_num_decimals(value) 21 | assert result == expected 22 | 23 | 24 | @pytest.mark.parametrize( 25 | ('value', 'expected'), 26 | ( 27 | (Decimal('42'), True), 28 | (Decimal('42.0'), True), 29 | (Decimal('0.25'), False), 30 | (Decimal('1.25e-3'), False), 31 | (Decimal('1e-17'), False), 32 | (Decimal('1.333e3'), True), 33 | ), 34 | ) 35 | def test_is_integer_decimal(value: Decimal, expected: bool): 36 | result = is_integer_decimal(value) 37 | assert result == expected 38 | 39 | 40 | @pytest.mark.parametrize( 41 | ('start', 'end', 'expected'), 42 | ( 43 | ('1', '10', '1'), 44 | ('0', '1.00', '1'), 45 | ('0.1', '10', '0.1'), 46 | ('0.1', '0.333', '0.001'), 47 | # 48 | ('-1', '-10', '1'), 49 | ), 50 | ) 51 | def test_default_range_step(start, end, expected): 52 | result = get_default_range_step(Decimal(start), Decimal(end)) 53 | assert result == Decimal(expected) 54 | 55 | 56 | @pytest.mark.parametrize( 57 | ('value', 'start', 'end', 'expected'), 58 | ( 59 | ('5', '1', '10', '5'), 60 | ('0', '1', '10', '1'), 61 | ('50', '1', '10', '10'), 62 | # 63 | ('-5', '-1', '-10', '-5'), 64 | ('0', '-1', '-10', '-1'), 65 | ('-50', '-1', '-10', '-10'), 66 | ), 67 | ) 68 | def test_clamp_to_range(value, start, end, expected): 69 | result = clamp_to_range(Decimal(value), Decimal(start), Decimal(end)) 70 | assert result == Decimal(expected) 71 | -------------------------------------------------------------------------------- /clisnips/tui/widgets/table/store.py: -------------------------------------------------------------------------------- 1 | import enum 2 | from collections.abc import Callable 3 | from typing import Any 4 | 5 | import urwid 6 | 7 | 8 | class TableStore: 9 | class Signals(enum.StrEnum): 10 | ROWS_LOADED = 'rows-loaded' 11 | ROW_UPDATED = 'row-updated' 12 | ROW_INSERTED = 'row-inserted' 13 | ROW_DELETED = 'row-deleted' 14 | 15 | def __init__(self): 16 | self._rows = [] 17 | urwid.register_signal(self.__class__, list(self.Signals)) 18 | 19 | def load(self, rows): 20 | self._rows = rows 21 | self.emit(self.Signals.ROWS_LOADED) 22 | 23 | def update(self, index, row): 24 | self._rows[index] = row 25 | self.emit(self.Signals.ROW_UPDATED, index, row) 26 | 27 | def insert(self, index, row): 28 | self._rows.insert(index, row) 29 | self.emit(self.Signals.ROW_INSERTED, index, row) 30 | 31 | def append(self, row): 32 | self._rows.append(row) 33 | self.emit(self.Signals.ROW_INSERTED, len(self._rows) - 1, row) 34 | 35 | def delete(self, index): 36 | self._rows.pop(index) 37 | self.emit(self.Signals.ROW_DELETED, index) 38 | 39 | def find(self, callback: Callable[[Any], bool]) -> tuple[int, Any] | tuple[None, None]: 40 | for index, row in enumerate(self._rows): 41 | if callback(row): 42 | return index, row 43 | return None, None 44 | 45 | @property 46 | def rows(self): 47 | return self._rows 48 | 49 | def emit(self, signal: Signals, *args): 50 | urwid.emit_signal(self, signal, self, *args) 51 | 52 | def connect(self, signal: Signals, callback): 53 | urwid.connect_signal(self, signal, callback) 54 | 55 | def __getitem__(self, key: int | slice): 56 | if isinstance(key, int | slice): 57 | return self._rows[key] 58 | raise TypeError(f'Table store indices must be int or slice, not {type(key).__name__}') 59 | 60 | def __len__(self): 61 | return len(self._rows) 62 | 63 | def __iter__(self): 64 | return iter(self._rows) 65 | -------------------------------------------------------------------------------- /clisnips/tui/view.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable, Hashable 2 | 3 | import urwid 4 | 5 | from .widgets.dialog import Dialog, DialogFrame, DialogOverlay 6 | from .widgets.dialogs.error import ErrorDialog 7 | 8 | 9 | class View(urwid.WidgetWrap): 10 | """ 11 | View instances act like controllers for their child widgets, 12 | and can interact directly with the main application instance. 13 | """ 14 | 15 | def __init__(self, view: urwid.Widget): 16 | self._view = view 17 | self._wrapped_widget = urwid.AttrMap(self._view, 'default') 18 | super().__init__(self._wrapped_widget) 19 | self._has_dialog = False 20 | 21 | def open_dialog(self, dialog: Dialog, title: str = '', width=('relative', 80), height=('relative', 80)): 22 | frame = DialogFrame(self, dialog, title=title) 23 | overlay = DialogOverlay(self, frame, self._view, align='center', width=width, valign='middle', height=height) 24 | self._wrapped_widget.original_widget = overlay 25 | self._has_dialog = True 26 | 27 | def close_dialog(self): 28 | self._wrapped_widget.original_widget = self._view 29 | self._has_dialog = False 30 | 31 | def show_exception_dialog(self, err: Exception): 32 | dialog = ErrorDialog(self, err) 33 | self.open_dialog(dialog, 'Error') 34 | 35 | 36 | BuildCallback = Callable[..., View] 37 | 38 | 39 | class ViewBuilder: 40 | """ 41 | Builds UI Views and attaches them to the root application widget. 42 | """ 43 | 44 | def __init__(self, root_widget: urwid.WidgetPlaceholder): 45 | self.root_widget = root_widget 46 | self._on_build_handlers: dict[Hashable, BuildCallback] = {} 47 | 48 | def register(self, view_id: Hashable, on_build: BuildCallback): 49 | self._on_build_handlers[view_id] = on_build 50 | 51 | def build(self, view_id: Hashable, display: bool = False, **kwargs) -> View: 52 | handler = self._on_build_handlers[view_id] 53 | view = handler(**kwargs) 54 | if display: 55 | self.root_widget.original_widget = view 56 | return view 57 | -------------------------------------------------------------------------------- /clisnips/resources/schema.sql: -------------------------------------------------------------------------------- 1 | 2 | -- Snippets table 3 | 4 | CREATE TABLE IF NOT EXISTS snippets( 5 | -- Statistics 6 | created_at INTEGER DEFAULT (strftime('%s', 'now')), 7 | last_used_at INTEGER DEFAULT (strftime('%s', 'now')), 8 | usage_count INTEGER DEFAULT 0, 9 | ranking FLOAT DEFAULT 0.0, 10 | -- Contents 11 | title TEXT NOT NULL, 12 | cmd TEXT NOT NULL, 13 | tag TEXT, 14 | doc TEXT 15 | ); 16 | 17 | -- Snippets search index 18 | 19 | CREATE VIRTUAL TABLE IF NOT EXISTS snippets_index USING fts5( 20 | tokenize = "unicode61 remove_diacritics 2 tokenchars '-_'", 21 | content = "snippets", 22 | title, 23 | tag 24 | ); 25 | 26 | -- Indexes for fast sorting 27 | 28 | CREATE INDEX IF NOT EXISTS snip_created_idx ON snippets(created_at DESC); 29 | CREATE INDEX IF NOT EXISTS snip_last_used_idx ON snippets(last_used_at DESC); 30 | CREATE INDEX IF NOT EXISTS snip_usage_idx ON snippets(usage_count DESC); 31 | CREATE INDEX IF NOT EXISTS snip_ranking_idx ON snippets(ranking DESC); 32 | 33 | -- 34 | -- Triggers to keep snippets table and index in sync, 35 | -- and to keep track of command usage and ranking. 36 | -- 37 | 38 | -- drops triggers for old versions (we'll need a migration system at some point) 39 | DROP TRIGGER IF EXISTS snippets_after_insert; 40 | DROP TRIGGER IF EXISTS snippets_after_update; 41 | 42 | CREATE TRIGGER IF NOT EXISTS snippets_after_insert 43 | AFTER INSERT ON snippets 44 | BEGIN 45 | INSERT INTO snippets_index(rowid, title, tag) VALUES(NEW.rowid, NEW.title, NEW.tag); 46 | END; 47 | 48 | 49 | CREATE TRIGGER IF NOT EXISTS snippets_before_delete 50 | BEFORE DELETE ON snippets 51 | BEGIN 52 | DELETE FROM snippets_index WHERE rowid=OLD.rowid; 53 | END; 54 | 55 | 56 | CREATE TRIGGER IF NOT EXISTS snippets_before_update 57 | BEFORE UPDATE ON snippets 58 | BEGIN 59 | DELETE FROM snippets_index WHERE rowid=OLD.rowid; 60 | END; 61 | 62 | CREATE TRIGGER IF NOT EXISTS snippets_after_update 63 | AFTER UPDATE ON snippets 64 | BEGIN 65 | INSERT INTO snippets_index(rowid, title, tag) VALUES(NEW.rowid, NEW.title, NEW.tag); 66 | END; 67 | 68 | -- Analyze the whole stuff 69 | ANALYZE; 70 | -------------------------------------------------------------------------------- /clisnips/tui/widgets/progress/worker.py: -------------------------------------------------------------------------------- 1 | from inspect import isgenerator, isgeneratorfunction 2 | 3 | from .message_queue import IndeterminateMessageQueueListener, MessageQueue, MessageQueueListener 4 | from .process import BlockingProcess, Process 5 | 6 | 7 | class Worker: 8 | @classmethod 9 | def from_job(cls, job, args=(), kwargs=None): 10 | queue = MessageQueue() 11 | if isgeneratorfunction(job) or isgenerator(job): 12 | process = Process(queue, job, args=args, kwargs=kwargs) 13 | listener = MessageQueueListener(queue) 14 | else: 15 | process = BlockingProcess(queue, job, args=args, kwargs=kwargs) 16 | listener = IndeterminateMessageQueueListener(queue) 17 | return cls(process, listener, queue) 18 | 19 | def __init__(self, process: Process, listener: MessageQueueListener, queue: MessageQueue): 20 | self.queue = queue 21 | self.listener = listener 22 | self.process = process 23 | self._destroyed = False 24 | 25 | def start(self): 26 | if self._destroyed: 27 | raise RuntimeError('Cannot reuse a Worker object.') 28 | self.listener.start() 29 | self.process.start() 30 | 31 | def stop(self): 32 | if self._destroyed: 33 | return 34 | if self.process.is_alive(): 35 | self.process.stop() 36 | self.process.join(1) 37 | self.listener.stop() 38 | self.listener.join() 39 | self.queue.close() 40 | self.cleanup() 41 | 42 | def is_running(self): 43 | return not self._destroyed and self.process.is_alive() 44 | 45 | def kill(self): 46 | if self._destroyed: 47 | return 48 | self.process.kill() 49 | self.listener.stop() 50 | self.listener.join() 51 | self.queue.close() 52 | self.cleanup() 53 | 54 | def cleanup(self): 55 | if self.listener: 56 | self.listener = None 57 | if self.queue: 58 | self.queue = None 59 | if self.process: 60 | self.process = None 61 | self._destroyed = True 62 | 63 | def __del__(self): 64 | self.cleanup() 65 | -------------------------------------------------------------------------------- /clisnips/database/pager.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Generic, Self, TypeVar 3 | 4 | from .snippets_db import QueryParameters 5 | 6 | 7 | Row = TypeVar('Row') 8 | Page = list[Row] 9 | 10 | 11 | class Pager(ABC, Generic[Row]): 12 | @property 13 | @abstractmethod 14 | def page_size(self) -> int: 15 | ... 16 | 17 | @property 18 | @abstractmethod 19 | def page_count(self) -> int: 20 | ... 21 | 22 | @property 23 | @abstractmethod 24 | def current_page(self) -> int: 25 | ... 26 | 27 | @property 28 | @abstractmethod 29 | def is_first_page(self) -> bool: 30 | ... 31 | 32 | @property 33 | @abstractmethod 34 | def is_last_page(self) -> bool: 35 | ... 36 | 37 | @property 38 | @abstractmethod 39 | def must_paginate(self) -> bool: 40 | ... 41 | 42 | @property 43 | @abstractmethod 44 | def total_rows(self) -> int: 45 | ... 46 | 47 | @abstractmethod 48 | def set_query(self, query: str, params: QueryParameters = ()): 49 | ... 50 | 51 | @abstractmethod 52 | def get_query(self) -> str: 53 | ... 54 | 55 | @abstractmethod 56 | def set_count_query(self, query: str, params: QueryParameters = ()): 57 | ... 58 | 59 | @abstractmethod 60 | def set_page_size(self, size: int): 61 | ... 62 | 63 | @abstractmethod 64 | def execute(self, params: QueryParameters = (), count_params: QueryParameters = ()) -> Self: 65 | ... 66 | 67 | @abstractmethod 68 | def get_page(self, page: int) -> Page[Row]: 69 | ... 70 | 71 | @abstractmethod 72 | def first(self) -> Page[Row]: 73 | ... 74 | 75 | @abstractmethod 76 | def last(self) -> Page[Row]: 77 | ... 78 | 79 | @abstractmethod 80 | def next(self) -> Page[Row]: 81 | ... 82 | 83 | @abstractmethod 84 | def previous(self) -> Page[Row]: 85 | ... 86 | 87 | def __len__(self) -> int: 88 | return self.page_count 89 | 90 | @abstractmethod 91 | def count(self): 92 | """ 93 | Updates the pager internal count by re-querying the database. 94 | """ 95 | ... 96 | -------------------------------------------------------------------------------- /clisnips/tui/components/snippets_list.py: -------------------------------------------------------------------------------- 1 | import urwid 2 | 3 | from clisnips.database import Snippet 4 | from clisnips.stores.snippets import SnippetsStore 5 | from clisnips.tui.widgets.list_box import CyclingFocusListBox 6 | 7 | ATTR_MAP = { 8 | None: 'snippets-list', 9 | 'title': 'snippets-list:title', 10 | 'tag': 'snippets-list:tag', 11 | 'cmd': 'snippets-list:cmd', 12 | } 13 | 14 | FOCUS_ATTR_MAP = { 15 | None: 'snippets-list:focused', 16 | 'title': 'snippets-list:title:focused', 17 | 'tag': 'snippets-list:tag:focused', 18 | 'cmd': 'snippets-list:cmd:focused', 19 | } 20 | 21 | 22 | class SnippetsList(urwid.WidgetWrap): 23 | def __init__(self, store: SnippetsStore): 24 | self._store = store 25 | self._walker = urwid.SimpleFocusListWalker([]) 26 | super().__init__(CyclingFocusListBox(self._walker)) 27 | 28 | def watch_snippets(state): 29 | return [state['snippets_by_id'][k] for k in state['snippet_ids']] 30 | 31 | def on_snippets_changed(snippets: list[Snippet]): 32 | self._walker.clear() 33 | for snippet in snippets: 34 | self._walker.append( 35 | urwid.AttrMap( 36 | ListItem(snippet), 37 | attr_map=ATTR_MAP, 38 | focus_map=FOCUS_ATTR_MAP, 39 | ) 40 | ) 41 | 42 | self._watcher = store.watch(watch_snippets, on_snippets_changed, immediate=True) 43 | 44 | def get_selected_index(self) -> int | None: 45 | _, index = self._walker.get_focus() 46 | return index 47 | 48 | def set_selected_index(self, index: int): 49 | self._walker.set_focus(index) 50 | 51 | 52 | class ListItem(urwid.Pile): 53 | def __init__(self, snippet: Snippet): 54 | header = urwid.Columns( 55 | [ 56 | ('weight', 1, urwid.Text(('title', snippet['title']))), 57 | ('pack', urwid.Text(('tag', f'[{snippet["tag"]}]'))), 58 | ], 59 | dividechars=1, 60 | ) 61 | super().__init__( 62 | [ 63 | ('pack', header), 64 | ('pack', urwid.Text(('cmd', snippet['cmd']))), 65 | ] 66 | ) 67 | 68 | def selectable(self) -> bool: 69 | return True 70 | -------------------------------------------------------------------------------- /clisnips/syntax/llk_parser.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, Never 2 | 3 | from clisnips.exceptions import ParseError 4 | from clisnips.syntax.string_lexer import StringLexer 5 | from clisnips.syntax.token import Kind, Token 6 | 7 | 8 | class LLkParser(Generic[Kind]): 9 | def __init__(self, lexer: StringLexer[Kind], eof: Kind, k: int = 2): 10 | self._K = k 11 | self._eof_marker = Token(eof, 0, 0) 12 | self.lexer = lexer 13 | self.token_stream = iter(self.lexer) 14 | self.position = 0 15 | self._buffer = [self._eof_marker for _ in range(self._K)] 16 | 17 | def reset(self): 18 | self.lexer.reset() 19 | self.token_stream = iter(self.lexer) 20 | self.position = 0 21 | self._buffer = [self._eof_marker for _ in range(self._K)] 22 | for _ in range(self._K): 23 | self._consume() 24 | 25 | def _match(self, *kinds: Kind): 26 | token = self._ensure(*kinds) 27 | self._consume() 28 | return token 29 | 30 | def _ensure(self, *kinds: Kind) -> Token[Kind]: 31 | token = self._lookahead() 32 | if token.kind not in kinds: 33 | self._unexpected_token(token, *kinds) 34 | return token 35 | 36 | def _consume(self): 37 | try: 38 | token = next(self.token_stream) 39 | except StopIteration: 40 | token = self._eof_marker 41 | self._buffer[self.position] = token 42 | self.position = (self.position + 1) % self._K 43 | 44 | def _consume_until(self, *kinds: Kind): 45 | while self._lookahead_kind() not in kinds: 46 | self._consume() 47 | 48 | def _current(self) -> Token[Kind] | None: 49 | return self._buffer[self.position] 50 | 51 | def _lookahead(self, offset: int = 1) -> Token[Kind]: 52 | return self._buffer[(self.position + offset - 1) % self._K] 53 | 54 | def _lookahead_kind(self, offset: int = 1) -> Kind: 55 | return self._lookahead(offset).kind 56 | 57 | def _unexpected_token(self, token: Token[Kind], *expected: Kind) -> Never: 58 | exp = ', '.join(t.name for t in expected) 59 | raise ParseError( 60 | f'Unexpected token: {token.name}' 61 | f' on line {token.start_line + 1}, column {token.start_col + 1}' 62 | f' (expected {exp})' 63 | ) 64 | -------------------------------------------------------------------------------- /clisnips/dic.py: -------------------------------------------------------------------------------- 1 | from clisnips.cli.utils import UrwidMarkupHelper 2 | from .config import Config 3 | from .config.state import PersistentState, load_persistent_state 4 | from .database.search_pager import SearchPager 5 | from .database.snippets_db import SnippetsDatabase 6 | from .stores.snippets import SnippetsStore 7 | from .ty import AnyPath 8 | from .utils.clock import Clock, SystemClock 9 | 10 | 11 | class DependencyInjectionContainer: 12 | def __init__(self, database: AnyPath | None = None): 13 | self._parameters = { 14 | 'database': database, 15 | } 16 | self._config: Config | None = None 17 | self._persitent_state: PersistentState | None = None 18 | self._database: SnippetsDatabase | None = None 19 | self._pager: SearchPager | None = None 20 | self._snippets_store: SnippetsStore | None = None 21 | self._clock: Clock = SystemClock() 22 | self._markup_helper: UrwidMarkupHelper | None = None 23 | 24 | @property 25 | def config(self) -> Config: 26 | if not self._config: 27 | self._config = Config() 28 | return self._config 29 | 30 | @property 31 | def database(self) -> SnippetsDatabase: 32 | if not self._database: 33 | self._database = self.open_database(self._parameters.get('database')) 34 | return self._database 35 | 36 | def open_database(self, path: AnyPath | None = None) -> SnippetsDatabase: 37 | path = path or self.config.database_path 38 | return SnippetsDatabase.open(path) 39 | 40 | @property 41 | def snippets_store(self) -> SnippetsStore: 42 | if not self._snippets_store: 43 | state = SnippetsStore.default_state() 44 | state.update(self.persistent_state) 45 | self._snippets_store = SnippetsStore(state, self.database, self.pager, self._clock) 46 | return self._snippets_store 47 | 48 | @property 49 | def pager(self): 50 | if not self._pager: 51 | self._pager = SearchPager(self.database) 52 | return self._pager 53 | 54 | @property 55 | def persistent_state(self) -> PersistentState: 56 | if not self._persitent_state: 57 | self._persitent_state = load_persistent_state() 58 | return self._persitent_state 59 | 60 | @property 61 | def markup_helper(self) -> UrwidMarkupHelper: 62 | if not self._markup_helper: 63 | self._markup_helper = UrwidMarkupHelper() 64 | return self._markup_helper 65 | -------------------------------------------------------------------------------- /clisnips/database/__init__.py: -------------------------------------------------------------------------------- 1 | import time 2 | from enum import StrEnum, auto 3 | from typing import Self 4 | 5 | from pydantic import Field 6 | from typing_extensions import Annotated, TypedDict # noqa: UP035 (pydantic needs this) 7 | 8 | 9 | class ScrollDirection(StrEnum): 10 | FWD = auto() 11 | BWD = auto() 12 | 13 | def reversed(self) -> Self: 14 | match self: 15 | case ScrollDirection.FWD: 16 | return ScrollDirection.BWD 17 | case ScrollDirection.BWD: 18 | return ScrollDirection.FWD 19 | 20 | 21 | class SortOrder(StrEnum): 22 | ASC = 'ASC' 23 | DESC = 'DESC' 24 | 25 | @classmethod 26 | def _missing_(cls, value: object) -> Self | None: 27 | match str(value).upper(): 28 | case 'ASC': 29 | return SortOrder.ASC 30 | case 'DESC': 31 | return SortOrder.DESC 32 | case _: 33 | return None 34 | 35 | def reversed(self) -> Self: 36 | match self: 37 | case SortOrder.ASC: 38 | return SortOrder.DESC 39 | case SortOrder.DESC: 40 | return SortOrder.ASC 41 | 42 | 43 | class Column(StrEnum): 44 | ID = 'rowid' 45 | TITLE = auto() 46 | CMD = auto() 47 | TAGS = auto() 48 | DOC = auto() 49 | RANKING = auto() 50 | USAGE_COUNT = auto() 51 | LAST_USED_AT = auto() 52 | CREATED_AT = auto() 53 | 54 | 55 | class SortColumn(StrEnum): 56 | RANKING = str(Column.RANKING) 57 | USAGE_COUNT = str(Column.USAGE_COUNT) 58 | LAST_USED_AT = str(Column.LAST_USED_AT) 59 | CREATED_AT = str(Column.CREATED_AT) 60 | 61 | 62 | class Snippet(TypedDict): 63 | id: int 64 | title: str 65 | cmd: str 66 | tag: str 67 | doc: str 68 | created_at: int 69 | last_used_at: int 70 | usage_count: int 71 | ranking: float 72 | 73 | 74 | class NewSnippet(TypedDict): 75 | title: str 76 | cmd: str 77 | tag: str 78 | doc: str 79 | 80 | 81 | class ImportableSnippet(TypedDict): 82 | title: Annotated[str, Field(min_length=1)] 83 | cmd: Annotated[str, Field(min_length=1)] 84 | tag: Annotated[str, Field(default='')] 85 | doc: Annotated[str, Field(default='')] 86 | created_at: Annotated[int, Field(ge=0, default_factory=lambda: int(time.time()))] 87 | last_used_at: Annotated[int, Field(ge=0, default=0)] 88 | usage_count: Annotated[int, Field(ge=0, default=0)] 89 | ranking: Annotated[float, Field(ge=0.0, default=0.0)] 90 | -------------------------------------------------------------------------------- /clisnips/tui/highlighters/command.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from pygments import highlight 4 | from pygments.formatter import Formatter 5 | from pygments.lexer import RegexLexer, bygroups 6 | from pygments.token import Punctuation, Text, Token 7 | 8 | from clisnips.tui.urwid_types import TextMarkup 9 | 10 | from .writer import UrwidMarkupWriter 11 | 12 | Command = Token.Command 13 | 14 | IDENTIFIER = r'[a-z][a-z0-]*' 15 | DIGIT = r'\d+' 16 | ARG_NAME = rf'(?: {IDENTIFIER} | {DIGIT} )' 17 | INDEX_STR = r'[^\]]+' 18 | ELEMENT_INDEX = rf'\[ {DIGIT} | {INDEX_STR} \]' 19 | 20 | 21 | class CommandLexer(RegexLexer): 22 | name = 'ClisnipsCommand' 23 | aliases = 'cmd' 24 | flags = re.X | re.I 25 | 26 | tokens = { 27 | 'root': [ 28 | (r'(? TextMarkup: 71 | if not text: 72 | return '' 73 | _writer.clear() 74 | highlight(text, _lexer, _formatter, _writer) 75 | return _writer.get_markup() 76 | -------------------------------------------------------------------------------- /clisnips/cli/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | import urwid 5 | from urwid.util import decompose_tagmarkup 6 | 7 | from clisnips.tui.urwid_types import TextMarkup 8 | 9 | 10 | class _DummyScreen(urwid.raw_display.Screen): 11 | def __init__(self): 12 | super().__init__( 13 | input=os.devnull, # type: ignore 14 | output=os.devnull, # type: ignore 15 | bracketed_paste_mode=False, 16 | ) 17 | 18 | def convert_attr(self, spec: urwid.AttrSpec) -> str: 19 | return self._attrspec_to_escape(spec) # type: ignore 20 | 21 | def decompose_markup(self, markup: TextMarkup) -> tuple[str, list[tuple[str, int]]]: 22 | return decompose_tagmarkup(markup) # type: ignore 23 | 24 | 25 | class UrwidMarkupHelper: 26 | palette = { 27 | 'default': ('default', 'default'), 28 | 'accent': ('dark magenta', 'default'), 29 | 'success': ('dark green', 'default'), 30 | 'error': ('dark red', 'default'), 31 | 'warning': ('brown', 'default'), 32 | 'info': ('dark blue', 'default'), 33 | 'debug': ('dark cyan', 'default'), 34 | } 35 | 36 | def __init__(self): 37 | self._screen = _DummyScreen() 38 | self._palette_escapes: dict[str | None, str] = {} 39 | for name, attrs in self.palette.items(): 40 | escape = self._screen.convert_attr(urwid.AttrSpec(*attrs)) 41 | self._palette_escapes[name] = escape 42 | self._palette_escapes[None] = self._palette_escapes['default'] 43 | 44 | def print(self, *args: TextMarkup, stderr: bool = False, end: str = '\n', sep: str = ' '): 45 | stream = sys.stderr if stderr else sys.stdout 46 | tty = stream.isatty() 47 | output = sep.join(self.convert_markup(m, tty) for m in args) 48 | output += self.reset(tty) 49 | print(output, end=end, file=stream) 50 | 51 | def convert_markup(self, markup: TextMarkup, tty: bool = True) -> str: 52 | text, attributes = self._screen.decompose_markup(markup) 53 | if not tty: 54 | return str(text) 55 | pos = 0 56 | output: list[str] = [] 57 | for attr, length in attributes: 58 | try: 59 | escape = self._palette_escapes[attr] 60 | except KeyError: 61 | escape = self._palette_escapes['default'] 62 | chunk = text[pos : pos + length] 63 | output.append(f'{escape}{chunk}') 64 | pos += length 65 | return ''.join(output) 66 | 67 | def reset(self, tty: bool = True) -> str: 68 | return tty and self.convert_markup(('default', ''), tty=tty) or '' 69 | -------------------------------------------------------------------------------- /clisnips/syntax/documentation/nodes.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from dataclasses import dataclass 3 | 4 | from decimal import Decimal 5 | 6 | from clisnips.utils.number import clamp_to_range, get_default_range_step 7 | 8 | 9 | @dataclass(slots=True, frozen=True) 10 | class Documentation: 11 | header: str 12 | parameters: dict[str, Parameter] 13 | code_blocks: list[CodeBlock] 14 | 15 | def __str__(self): 16 | code = '\n'.join(str(c) for c in self.code_blocks) 17 | params = '\n'.join(str(p) for p in self.parameters.values()) 18 | return f'{self.header!s}{params}{code}' 19 | 20 | def __repr__(self): 21 | return str(self) 22 | 23 | 24 | @dataclass(slots=True) 25 | class Parameter: 26 | name: str 27 | type_hint: str | None = None 28 | value_hint: ValueList | ValueRange | None = None 29 | text: str = '' 30 | 31 | def __str__(self): 32 | return f'{{{self.name}}} ({self.type_hint}) {self.value_hint} {self.text!r}' 33 | 34 | def __repr__(self): 35 | return str(self) 36 | 37 | 38 | class ValueRange: 39 | def __init__( 40 | self, 41 | start: Decimal, 42 | end: Decimal, 43 | step: Decimal | None = None, 44 | default: Decimal | None = None, 45 | ): 46 | self.start = start 47 | self.end = end 48 | self.step = get_default_range_step(start, end) if step is None else step 49 | if default is None: 50 | default = start 51 | self.default = clamp_to_range(default, start, end) 52 | 53 | def __str__(self): 54 | return '[%s..%s:%s*%s]' % (self.start, self.end, self.step, self.default) # noqa: UP031 (this is more readable) 55 | 56 | def __repr__(self): 57 | return str(self) 58 | 59 | 60 | Value = str | Decimal 61 | 62 | 63 | @dataclass(slots=True, frozen=True) 64 | class ValueList: 65 | values: list[Value] 66 | default: int = 0 67 | 68 | def get_default_value(self) -> Value: 69 | return self.values[self.default] 70 | 71 | def __len__(self) -> int: 72 | return len(self.values) 73 | 74 | def __str__(self) -> str: 75 | values: list[str] = [] 76 | for i, value in enumerate(self.values): 77 | value = repr(value) 78 | if i == self.default: 79 | value = f'=>{value}' 80 | values.append(value) 81 | return '[%s]' % ', '.join(values) 82 | 83 | def __repr__(self): 84 | return str(self) 85 | 86 | 87 | @dataclass(slots=True, frozen=True) 88 | class CodeBlock: 89 | code: str 90 | 91 | def __str__(self): 92 | return f'```\n{self.code}\n```' 93 | 94 | def __repr__(self): 95 | return str(self) 96 | -------------------------------------------------------------------------------- /clisnips/tui/components/snippets_table.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import urwid 4 | from urwid.widget.constants import WrapMode 5 | 6 | from clisnips.database import Snippet 7 | from clisnips.stores.snippets import SnippetsStore, State 8 | from clisnips.tui.layouts.table import LayoutColumn, LayoutRow, TableLayout 9 | from clisnips.tui.widgets.list_box import CyclingFocusListBox 10 | 11 | ATTR_MAP = { 12 | None: 'snippets-list', 13 | 'title': 'snippets-list:title', 14 | 'tag': 'snippets-list:tag', 15 | 'cmd': 'snippets-list:cmd', 16 | } 17 | 18 | FOCUS_ATTR_MAP = { 19 | None: 'snippets-list:focused', 20 | 'title': 'snippets-list:title:focused', 21 | 'tag': 'snippets-list:tag:focused', 22 | 'cmd': 'snippets-list:cmd:focused', 23 | } 24 | 25 | 26 | class SnippetsTable(urwid.WidgetWrap): 27 | def __init__(self, store: SnippetsStore): 28 | self._store = store 29 | self._walker = urwid.SimpleFocusListWalker([]) 30 | super().__init__(CyclingFocusListBox(self._walker)) 31 | 32 | layout: TableLayout[Snippet] = TableLayout() 33 | layout.append_column(LayoutColumn('tag')) 34 | layout.append_column(LayoutColumn('title', wrap=True)) 35 | layout.append_column(LayoutColumn('cmd', wrap=True)) 36 | 37 | def watch_snippets(state: State): 38 | width, _ = state['viewport'] 39 | return width, [state['snippets_by_id'][k] for k in state['snippet_ids']] 40 | 41 | def on_snippets_changed(args: tuple[int, list[Snippet]]): 42 | width, snippets = args 43 | logging.getLogger(__name__).debug(f'width={width}') 44 | layout.invalidate() 45 | layout.layout(snippets, width) 46 | self._walker.clear() 47 | for index, row in enumerate(layout): 48 | row = urwid.AttrMap(ListItem(row), ATTR_MAP, FOCUS_ATTR_MAP) 49 | self._walker.append(row) 50 | self._invalidate() 51 | 52 | self._watcher = store.watch(watch_snippets, on_snippets_changed, immediate=True, sync=False) 53 | 54 | def get_selected_index(self) -> int | None: 55 | _, index = self._walker.get_focus() 56 | return index 57 | 58 | def set_selected_index(self, index: int): 59 | self._walker.set_focus(index) 60 | 61 | 62 | class ListItem(urwid.Columns): 63 | def __init__(self, row: LayoutRow[Snippet]): 64 | cols = [] 65 | for column, value in row: 66 | cell = urwid.Text((column.key, value), wrap=WrapMode.SPACE if column.word_wrap else WrapMode.ANY) 67 | cols.append((column.computed_width, cell)) 68 | 69 | super().__init__(cols, dividechars=1) 70 | 71 | def selectable(self) -> bool: 72 | return True 73 | -------------------------------------------------------------------------------- /clisnips/tui/tui.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | import signal 3 | import sys 4 | from collections.abc import Callable, Hashable, Iterable 5 | 6 | import observ 7 | import urwid 8 | 9 | from clisnips.config.palette import Palette 10 | 11 | from .loop import get_event_loop 12 | from .view import View, ViewBuilder 13 | 14 | 15 | class TUI: 16 | def __init__(self, palette: Palette): 17 | self.root_widget = urwid.WidgetPlaceholder(urwid.SolidFill('')) 18 | self.builder = ViewBuilder(self.root_widget) 19 | # Since our main purpose is to insert stuff in the tty command line, we send the screen to STDERR 20 | # so we can capture stdout easily without swapping file descriptors 21 | screen = urwid.raw_display.Screen(output=sys.stderr) 22 | for name, entry in palette.items(): 23 | screen.register_palette_entry( 24 | name, entry['fg'], entry['bg'], entry.get('mono'), entry.get('fg_hi'), entry.get('bg_hi') 25 | ) 26 | 27 | observ.scheduler.register_asyncio() 28 | self.main_loop = urwid.MainLoop( 29 | self.root_widget, 30 | handle_mouse=False, 31 | pop_ups=True, 32 | screen=screen, 33 | event_loop=get_event_loop(), 34 | unhandled_input=self._on_unhandled_input, 35 | ) 36 | 37 | def register_view(self, name: Hashable, build_callback: Callable): 38 | self.builder.register(name, build_callback) 39 | 40 | def build_view(self, name: Hashable, display: bool, **kwargs) -> View: 41 | view = self.builder.build(name, display, **kwargs) 42 | return view 43 | 44 | def refresh(self): 45 | if self.main_loop.screen.started: 46 | self.main_loop.draw_screen() 47 | 48 | @staticmethod 49 | def connect(obj: object, name: Hashable, callback: Callable, weak_args: Iterable = (), user_args: Iterable = ()): 50 | urwid.connect_signal(obj, name, callback, weak_args=weak_args, user_args=user_args) 51 | 52 | def main(self): 53 | signal.signal(signal.SIGINT, self._on_terminate_signal) 54 | signal.signal(signal.SIGQUIT, self._on_terminate_signal) 55 | signal.signal(signal.SIGTERM, self._on_terminate_signal) 56 | 57 | self.main_loop.run() 58 | 59 | def stop(self): 60 | raise urwid.ExitMainLoop() 61 | 62 | def exit_with_message(self, message: str): 63 | atexit.register(lambda: print(message, sep='')) 64 | self.main_loop.screen.clear() 65 | self.stop() 66 | 67 | def _on_unhandled_input(self, key) -> bool: 68 | if key in ('esc', 'q'): 69 | self.stop() 70 | return True 71 | return False 72 | 73 | @staticmethod 74 | def _on_terminate_signal(signum, frame): 75 | raise urwid.ExitMainLoop() 76 | -------------------------------------------------------------------------------- /clisnips/tui/widgets/progress/process.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import multiprocessing 3 | import os 4 | import signal 5 | from collections.abc import Callable 6 | from typing import Any 7 | 8 | from .message_queue import MessageQueue 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class Process(multiprocessing.Process): 14 | # protected props of multiprocessing.Process 15 | _target: Callable 16 | _args: tuple[Any] 17 | _kwargs: dict[str, Any] 18 | 19 | def __init__(self, message_queue: MessageQueue, target, args=(), kwargs=None): 20 | super().__init__(target=target, args=args, kwargs=kwargs or {}) 21 | self._stop_event = multiprocessing.Event() 22 | self._message_queue = message_queue 23 | 24 | def stop(self): 25 | logger.debug('Stopping process %s', self.pid) 26 | self._stop_event.set() 27 | # allow garbage collection 28 | if self._message_queue: 29 | self._message_queue = None 30 | if hasattr(self, '_target'): 31 | self._target.message_queue = None 32 | 33 | def kill(self): 34 | self.stop() 35 | if self.is_alive(): 36 | logger.debug('Killing process %s', self.pid) 37 | assert self.pid, 'Cannot kill a process without a pid' 38 | try: 39 | os.killpg(self.pid, signal.SIGKILL) 40 | except OSError: 41 | os.kill(self.pid, signal.SIGKILL) 42 | 43 | def run(self): 44 | logger.debug('Starting process %s', self.pid) 45 | assert self._message_queue, 'Process was already stopped' 46 | # pass the queue object to the function object 47 | self._target.message_queue = self._message_queue 48 | self._message_queue.start() 49 | self._message_queue.progress(0.0) 50 | try: 51 | self._do_run_task() 52 | except (KeyboardInterrupt, SystemExit): 53 | logger.debug('Process %s catched KeyboardInterrupt', self.pid) 54 | self._message_queue.cancel() 55 | except Exception as err: 56 | msg = ' '.join(err.args) if len(err.args) else str(err) 57 | self._message_queue.error(msg) 58 | finally: 59 | self._message_queue.finish() 60 | self._message_queue.close() 61 | 62 | def _do_run_task(self): 63 | assert self._message_queue, 'Process was already stopped' 64 | for msg in self._target(*self._args, **self._kwargs): 65 | if isinstance(msg, float): 66 | self._message_queue.progress(msg) 67 | elif isinstance(msg, str): 68 | self._message_queue.message(msg) 69 | if self._stop_event.is_set(): 70 | self._message_queue.cancel() 71 | logger.debug('Cancelled process %s', self.pid) 72 | break 73 | 74 | 75 | class BlockingProcess(Process): 76 | def _do_run_task(self): 77 | self._target(*self._args, **self._kwargs) 78 | -------------------------------------------------------------------------------- /clisnips/tui/widgets/progress/message_queue.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import multiprocessing 3 | import multiprocessing.queues 4 | import queue 5 | import threading 6 | 7 | import urwid 8 | 9 | from clisnips.tui.loop import idle_add 10 | 11 | 12 | class MessageType(enum.Enum): 13 | STARTED = 'started' 14 | FINISHED = 'finished' 15 | CANCELED = 'canceled' 16 | ERROR = 'error' 17 | MESSAGE = 'message' 18 | PROGRESS = 'progress' 19 | PULSE = 'pulse' 20 | 21 | 22 | class MessageQueue(multiprocessing.queues.Queue): 23 | def __init__(self): 24 | super().__init__(0, ctx=multiprocessing) 25 | 26 | def start(self): 27 | self.put((MessageType.STARTED,)) 28 | 29 | def finish(self): 30 | self.put((MessageType.FINISHED,)) 31 | 32 | def progress(self, fraction): 33 | self.put((MessageType.PROGRESS, fraction)) 34 | 35 | def pulse(self): 36 | self.put((MessageType.PULSE,)) 37 | 38 | def message(self, msg): 39 | self.put((MessageType.MESSAGE, msg)) 40 | 41 | def error(self, err): 42 | self.put((MessageType.ERROR, err)) 43 | 44 | def cancel(self): 45 | self.put((MessageType.CANCELED,)) 46 | 47 | 48 | class MessageQueueListener: 49 | def __init__(self, message_queue: MessageQueue): 50 | self._message_queue = message_queue 51 | self._stop_event = multiprocessing.Event() 52 | self._thread = threading.Thread(target=self.run, args=()) 53 | urwid.register_signal(self.__class__, list(MessageType)) 54 | self._emitter = self 55 | 56 | def emit(self, signal, *args): 57 | """Ensures signal is emitted in main thread""" 58 | idle_add(lambda *x: urwid.emit_signal(self, signal, *args)) 59 | 60 | def connect(self, signal, callback, *args): 61 | urwid.connect_signal(self, signal, callback, user_args=args) 62 | 63 | def start(self): 64 | self._thread.start() 65 | 66 | def stop(self): 67 | self._stop_event.set() 68 | # allow garbage collection 69 | if self._message_queue: 70 | self._message_queue = None 71 | 72 | def join(self): 73 | self._thread.join() 74 | 75 | def run(self): 76 | while not self._stop_event.is_set(): 77 | # Listen for results on the queue and process them accordingly 78 | try: 79 | data = self._poll_queue() 80 | except queue.Empty: 81 | continue 82 | message_type, *args = data 83 | self.emit(message_type, *args) 84 | if message_type in (MessageType.FINISHED, MessageType.CANCELED, MessageType.ERROR): 85 | self.stop() 86 | 87 | def _poll_queue(self): 88 | return self._message_queue.get() 89 | 90 | 91 | class IndeterminateMessageQueueListener(MessageQueueListener): 92 | def _poll_queue(self): 93 | try: 94 | return self._message_queue.get(True, 0.1) 95 | except queue.Empty: 96 | return MessageType.PULSE, None 97 | -------------------------------------------------------------------------------- /clisnips/tui/widgets/field/range.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | import urwid 3 | 4 | from clisnips.tui.urwid_types import TextMarkup 5 | from clisnips.tui.widgets.edit import EmacsEdit 6 | from clisnips.utils.number import get_num_decimals 7 | 8 | from .field import Entry, SimpleField 9 | 10 | 11 | class RangeField(SimpleField): 12 | def __init__(self, label: TextMarkup, *args, **kwargs): 13 | entry = RangeEntry(*args, **kwargs) 14 | super().__init__(label, entry) 15 | 16 | 17 | class RangeEntry(Entry[Decimal], EmacsEdit): 18 | def __init__(self, start: Decimal, end: Decimal, step: Decimal, default: Decimal | None = None): 19 | self._model = RangeModel(start, end, step, default) 20 | super().__init__('', str(self._model.get_value())) 21 | urwid.connect_signal(self, 'change', self._on_change) 22 | urwid.connect_signal(self, 'postchange', lambda *x: self._emit('changed')) 23 | 24 | def keypress(self, size, key): 25 | if key == '+': 26 | self._increment() 27 | return 28 | elif key == '-': 29 | self._decrement() 30 | return 31 | return super().keypress(size, key) 32 | 33 | def get_value(self) -> Decimal: 34 | return self._model.get_numeric_value() 35 | 36 | def _on_change(self, entry, value): 37 | self._model.set_value(value) 38 | 39 | def _increment(self): 40 | value = self.get_edit_text() 41 | if not value: 42 | return 43 | self._model.set_value(value) 44 | self._model.increment() 45 | self.set_edit_text(self._model.get_value()) 46 | 47 | def _decrement(self): 48 | value = self.get_edit_text() 49 | if not value: 50 | return 51 | self._model.set_value(value) 52 | self._model.decrement() 53 | self.set_edit_text(self._model.get_value()) 54 | 55 | 56 | class RangeModel: 57 | def __init__(self, start: Decimal, end: Decimal, step: Decimal, default: Decimal | None = None): 58 | self._start = start 59 | self._end = end 60 | self._step = step 61 | decimals = [get_num_decimals(x) for x in (start, end, step)] 62 | self._num_decimals = max(decimals) 63 | self._value: Decimal = Decimal('0') 64 | self.set_numeric_value(default if default is not None else start) 65 | 66 | def get_value(self) -> str: 67 | return str(self.get_numeric_value()) 68 | 69 | def set_value(self, value: str): 70 | try: 71 | v = Decimal(value) 72 | except Exception: 73 | return 74 | self.set_numeric_value(v) 75 | 76 | def get_numeric_value(self) -> Decimal: 77 | return round(self._value, self._num_decimals) 78 | 79 | def set_numeric_value(self, value: Decimal): 80 | self._value = min(self._end, max(self._start, value)) 81 | 82 | def increment(self): 83 | self.set_numeric_value(self._value + self._step) 84 | 85 | def decrement(self): 86 | self.set_numeric_value(self._value - self._step) 87 | -------------------------------------------------------------------------------- /tests/syntax/command/renderer_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from clisnips.syntax.command.err import InterpolationError, InterpolationErrorGroup, InvalidContext 3 | 4 | from clisnips.syntax.command.parser import parse 5 | from clisnips.syntax.command.renderer import Renderer 6 | 7 | TEXT = 'text' 8 | FIELD = 'field' 9 | 10 | 11 | @pytest.mark.parametrize( 12 | ('raw', 'context', 'expected_str', 'expected_markup'), 13 | ( 14 | ( 15 | 'foo', 16 | {'bar': 'baz'}, 17 | 'foo', 18 | [(TEXT, 'foo')], 19 | ), 20 | ( 21 | 'foo {bar}', 22 | {'bar': 'baz'}, 23 | 'foo baz', 24 | [(TEXT, 'foo '), (FIELD, 'baz')], 25 | ), 26 | ( 27 | 'foo {bar!r}', 28 | {'bar': 'baz'}, 29 | "foo 'baz'", 30 | [(TEXT, 'foo '), (FIELD, "'baz'")], 31 | ), 32 | ( 33 | 'foo {poo:🤟^5}', 34 | {'poo': '💩'}, 35 | 'foo 🤟🤟💩🤟🤟', 36 | [(TEXT, 'foo '), (FIELD, '🤟🤟💩🤟🤟')], 37 | ), 38 | ( 39 | 'x={v:.3f}', 40 | {'v': 1 / 3}, 41 | 'x=0.333', 42 | [(TEXT, 'x='), (FIELD, '0.333')], 43 | ), 44 | ( 45 | '0x{v:X}', 46 | {'v': 3735928559}, 47 | '0xDEADBEEF', 48 | [(TEXT, '0x'), (FIELD, 'DEADBEEF')], 49 | ), 50 | ( 51 | 'i haz {} {:.2f} fields', 52 | { 53 | '0': 'zaroo', 54 | '1': 1 / 3, 55 | 'foo': {'bar': 42}, 56 | }, 57 | 'i haz zaroo 0.33 fields', 58 | [ 59 | (TEXT, 'i haz '), 60 | (FIELD, 'zaroo'), 61 | (TEXT, ' '), 62 | (FIELD, '0.33'), 63 | (TEXT, ' fields'), 64 | ], 65 | ), 66 | ), 67 | ) 68 | def test_apply(raw, context, expected_str, expected_markup): 69 | tpl = parse(raw) 70 | renderer = Renderer() 71 | result = renderer.render_str(tpl, context) 72 | assert result == expected_str 73 | result = renderer.render_markup(tpl, context) 74 | assert result == expected_markup 75 | 76 | 77 | @pytest.mark.parametrize( 78 | ('raw', 'context'), 79 | ( 80 | ( 81 | '{v:X}', 82 | {'v': 'nope'}, 83 | ), 84 | ), 85 | ) 86 | def test_interpolation_errors(raw, context): 87 | tpl = parse(raw) 88 | with pytest.raises(InterpolationErrorGroup) as err: 89 | _ = Renderer().render_str(tpl, context) 90 | assert isinstance(err.value.exceptions[0], InterpolationError) 91 | 92 | 93 | def test_context_accepts_only_string_keys(): 94 | tpl = parse('{0:X}') 95 | renderer = Renderer() 96 | result = renderer.render_str(tpl, {'0': 666}) 97 | assert result == '29A' 98 | with pytest.raises(InterpolationErrorGroup) as err: 99 | _ = renderer.render_str(tpl, {0: 666}) # type: ignore (we're asserting that) 100 | assert isinstance(err.value.exceptions[0], InvalidContext) 101 | -------------------------------------------------------------------------------- /tests/syntax/command/parser_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from clisnips.exceptions import ParseError 4 | from clisnips.syntax.command.nodes import Field, Text 5 | from clisnips.syntax.command.parser import parse 6 | 7 | 8 | def test_no_replacement_fields(): 9 | raw = 'i haz no fields' 10 | cmd = parse(raw) 11 | expected = [Text(raw, 0, 15)] 12 | assert cmd.nodes == expected 13 | 14 | 15 | def test_simple_replacement_field(): 16 | raw = 'i haz {one} field' 17 | cmd = parse(raw) 18 | expected = [ 19 | Text('i haz ', 0, 6), 20 | Field('one', 6, 11), 21 | Text(' field', 11, 17), 22 | ] 23 | assert cmd.nodes == expected 24 | 25 | 26 | def test_it_supports_flags(): 27 | raw = 'i haz {-1} {--two} flags' 28 | cmd = parse(raw) 29 | expected = [ 30 | Text('i haz ', 0, 6), 31 | Field('-1', 6, 10), 32 | Text(' ', 10, 11), 33 | Field('--two', 11, 18), 34 | Text(' flags', 18, 24), 35 | ] 36 | assert cmd.nodes == expected 37 | 38 | 39 | def test_automatic_numbering(): 40 | raw = 'i haz {} {} flags' 41 | cmd = parse(raw) 42 | expected = [ 43 | Text('i haz ', 0, 6), 44 | Field('0', 6, 8), 45 | Text(' ', 8, 9), 46 | Field('1', 9, 11), 47 | Text(' flags', 11, 17), 48 | ] 49 | assert cmd.nodes == expected 50 | 51 | 52 | def test_conversion(): 53 | raw = 'i haz {one!r} field' 54 | cmd = parse(raw) 55 | expected = [ 56 | Text('i haz ', 0, 6), 57 | Field('one', 6, 13, '', 'r'), 58 | Text(' field', 13, 19), 59 | ] 60 | assert cmd.nodes == expected 61 | 62 | 63 | def test_invalidconversion(): 64 | raw = 'i haz {one!z} field' 65 | with pytest.raises(ParseError, match='Invalid conversion specifier'): 66 | _ = parse(raw) 67 | 68 | 69 | def test_format_spec(): 70 | raw = 'i haz {0:.1f} field' 71 | cmd = parse(raw) 72 | expected = [ 73 | Text('i haz ', 0, 6), 74 | Field('0', 6, 13, '.1f', None), 75 | Text(' field', 13, 19), 76 | ] 77 | assert cmd.nodes == expected 78 | 79 | 80 | def test_wierd_format_spec(): 81 | raw = 'i haz {wierd:||:[0]} spec' 82 | cmd = parse(raw) 83 | expected = [ 84 | Text('i haz ', 0, 6), 85 | Field('wierd', 6, 23, '||:[0]', None), 86 | Text(' spec', 23, 28), 87 | ] 88 | assert cmd.nodes == expected 89 | 90 | 91 | def test_it_cannot_switch_from_auto_to_manual_numbering(): 92 | raw = 'i haz {1} {} fields' 93 | with pytest.raises(ParseError, match='field numbering'): 94 | _ = parse(raw) 95 | 96 | 97 | def test_field_inside_format_spec(): 98 | raw = 'i haz {one:%s {2}} field' 99 | with pytest.raises(ParseError, match='not supported'): 100 | _ = parse(raw) 101 | 102 | 103 | def test_field_getitem(): 104 | raw = 'i haz {foo[bar]} field' 105 | with pytest.raises(ParseError): 106 | _ = parse(raw) 107 | 108 | 109 | def test_field_getattr(): 110 | raw = 'i haz {foo.bar} field' 111 | with pytest.raises(ParseError): 112 | _ = parse(raw) 113 | -------------------------------------------------------------------------------- /clisnips/cli/commands/_import.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | import shutil 4 | from pathlib import Path 5 | 6 | from pydantic import ValidationError 7 | 8 | from clisnips.ty import AnyPath 9 | 10 | from clisnips.cli.command import Command 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def configure(cmd: argparse.ArgumentParser): 16 | cmd.add_argument('-f', '--format', choices=('xml', 'json', 'toml', 'cli-companion'), default=None) 17 | cmd.add_argument('--replace', action='store_true', help='Replaces snippets. The default is to append.') 18 | cmd.add_argument('-D', '--dry-run', action='store_true', help='Just pretend.') 19 | cmd.add_argument('file', type=Path) 20 | 21 | return ImportCommand 22 | 23 | 24 | class ImportCommand(Command): 25 | def run(self, argv) -> int: 26 | cls = self._get_importer_class(argv.format, argv.file.suffix) 27 | if not cls: 28 | logger.error( 29 | f'Could not detect import format for {argv.file}.\n' 30 | 'Please provide an explicit format with the --format option.' 31 | ) 32 | return 1 33 | 34 | if argv.replace: 35 | db_path = argv.database or self.container.config.database_path 36 | self._backup_and_drop_db(db_path, argv.dry_run) 37 | db = self.container.open_database(db_path) 38 | else: 39 | db = self.container.database 40 | 41 | try: 42 | cls(db, dry_run=argv.dry_run).import_path(argv.file) 43 | except ValidationError as err: 44 | logger.error(err) 45 | return 128 46 | 47 | return 0 48 | 49 | def _get_importer_class(self, format: str | None, suffix: str): 50 | match format, suffix: 51 | case ('json', _) | (None, '.json'): 52 | from clisnips.importers import JsonImporter 53 | 54 | return JsonImporter 55 | case ('toml', _) | (None, '.toml'): 56 | from clisnips.importers import TomlImporter 57 | 58 | return TomlImporter 59 | case ('xml', _) | (None, '.xml'): 60 | from clisnips.importers import XmlImporter 61 | 62 | return XmlImporter 63 | case ('cli-companion', _): 64 | from clisnips.importers import CliCompanionImporter 65 | 66 | return CliCompanionImporter 67 | case _: 68 | return None 69 | 70 | def _backup_and_drop_db(self, db_path: AnyPath, dry_run: bool): 71 | backup_path = self._get_backup_path(db_path) 72 | 73 | logger.warning(f'Backing up database to {backup_path}') 74 | if not dry_run: 75 | shutil.copyfile(db_path, backup_path) 76 | 77 | logger.warning('Dropping database!') 78 | if not dry_run: 79 | Path(db_path).unlink(True) 80 | 81 | def _get_backup_path(self, path: AnyPath) -> Path: 82 | backup_path = Path(path).with_suffix('.bak') 83 | index = 0 84 | while backup_path.exists() and index < 64: 85 | backup_path = backup_path.with_suffix(f'.{index}.bak') 86 | index += 1 87 | return backup_path 88 | -------------------------------------------------------------------------------- /clisnips/tui/widgets/switch.py: -------------------------------------------------------------------------------- 1 | import enum 2 | from typing import Generic, TypeVar 3 | 4 | import observ 5 | import urwid 6 | from urwid.command_map import Command 7 | 8 | V = TypeVar('V') 9 | 10 | 11 | class _State(enum.StrEnum): 12 | OFF = enum.auto() 13 | ON = enum.auto() 14 | 15 | 16 | _LABELS = {_State.OFF: 'Off', _State.ON: 'On'} 17 | _STATES = {_State.OFF: False, _State.ON: True} 18 | 19 | 20 | class Switch(urwid.WidgetWrap, Generic[V]): 21 | class Signals(enum.StrEnum): 22 | CHANGED = enum.auto() 23 | 24 | signals = list(Signals) 25 | 26 | State = _State 27 | 28 | def __init__( 29 | self, 30 | state: _State = _State.OFF, 31 | caption: str = '', 32 | states: dict[_State, V] | None = None, 33 | labels: dict[_State, str] | None = None, 34 | ): 35 | self._icon = urwid.SelectableIcon('<=>', 1) 36 | self._states = states or _STATES 37 | self._values = {v: s for s, v in self._states.items()} 38 | 39 | labels = labels or _LABELS 40 | self._off_label = urwid.AttrMap(urwid.Text(labels[_State.OFF]), 'choice:inactive') 41 | self._on_label = urwid.AttrMap(urwid.Text(labels[_State.ON]), 'choice:inactive') 42 | 43 | inner = urwid.Columns( 44 | [ 45 | ('pack', urwid.Text(('caption', caption))), 46 | ('pack', self._off_label), 47 | ('pack', urwid.AttrMap(self._icon, 'icon', 'icon:focused')), 48 | ('pack', self._on_label), 49 | ], 50 | dividechars=1, 51 | focus_column=2, 52 | ) 53 | super().__init__(inner) 54 | self._state = observ.ref(state) 55 | self._watchers = { 56 | 'state': observ.watch(lambda: self._state['value'], self._handle_state_changed, immediate=True) 57 | } 58 | 59 | def set_value(self, value: V): 60 | self.set_state(self._values[value], emit=False) 61 | 62 | def sizing(self): 63 | return frozenset((urwid.Sizing.FLOW,)) 64 | 65 | def keypress(self, size: tuple[int], key: str) -> str | None: 66 | match self._command_map[key]: 67 | case Command.ACTIVATE: 68 | self.toggle_state(emit=True) 69 | case _: 70 | return key 71 | 72 | def set_state(self, new_state: _State, emit=False): 73 | self._state['value'] = new_state 74 | if emit: 75 | self._emit(self.Signals.CHANGED, self._states[new_state]) 76 | 77 | def toggle_state(self, emit=False): 78 | match self._state['value']: 79 | case _State.OFF: 80 | self.set_state(_State.ON, emit) 81 | case _State.ON: 82 | self.set_state(_State.OFF, emit) 83 | 84 | def _handle_state_changed(self, new_state, old_state): 85 | match new_state: 86 | case _State.OFF: 87 | self._icon.set_text('<==') 88 | self._off_label.attr_map = {None: 'choice:active'} 89 | self._on_label.attr_map = {None: 'choice:inactive'} 90 | case _State.ON: 91 | self._icon.set_text('==>') 92 | self._off_label.attr_map = {None: 'choice:inactive'} 93 | self._on_label.attr_map = {None: 'choice:active'} 94 | -------------------------------------------------------------------------------- /clisnips/syntax/command/parser.py: -------------------------------------------------------------------------------- 1 | import re 2 | from string import Formatter 3 | 4 | from clisnips.exceptions import CommandParseError 5 | 6 | from .nodes import CommandTemplate, Field, Text 7 | 8 | _FIELD_NAME_RX = re.compile( 9 | r""" 10 | ^ 11 | (--? [a-zA-Z0-9] [\w-]*) # cli flag 12 | | 13 | ( \d+ | [_a-zA-Z]\w* ) # digit or identifier 14 | $ 15 | """, 16 | re.X, 17 | ) 18 | 19 | __lexer = Formatter() 20 | 21 | 22 | def parse(subject: str) -> CommandTemplate: 23 | try: 24 | stream = list(__lexer.parse(subject)) 25 | except Exception as err: 26 | raise CommandParseError(str(err), subject) from None 27 | nodes = [] 28 | auto_count = -1 29 | has_explicit_numeric_field = False 30 | pos = 0 31 | for prefix, field_name, format_spec, conversion in stream: 32 | end = pos + len(prefix) 33 | nodes.append(Text(prefix, pos, end)) 34 | pos = end 35 | if field_name is None: 36 | continue 37 | 38 | name, start, end = '', pos, pos + 2 39 | if field_name: 40 | m = _FIELD_NAME_RX.match(field_name) 41 | if not m: 42 | raise CommandParseError(f'Invalid replacement field {field_name!r}', subject) 43 | name = m.group(1) if m.group(1) else m.group(2) 44 | end += len(name) 45 | if not name: 46 | if has_explicit_numeric_field: 47 | raise CommandParseError('Cannot switch from manual to automatic field numbering', subject) 48 | auto_count += 1 49 | name = str(auto_count) 50 | elif name.isdigit(): 51 | has_explicit_numeric_field = True 52 | if auto_count > -1: 53 | raise CommandParseError('Cannot switch from automatic to manual field numbering', subject) 54 | if format_spec: 55 | _check_format_spec(format_spec, subject) 56 | end += len(format_spec) + 1 57 | if conversion: 58 | _check_conversion_spec(conversion, subject) 59 | end += 2 60 | pos = end 61 | nodes.append(Field(name, start, end, format_spec, conversion)) 62 | return CommandTemplate(subject, nodes) 63 | 64 | 65 | # TODO: nested fields might be useful with code blocks, 66 | # so we might need to lift this restriction... 67 | def _check_format_spec(format_spec: str, subject: str): 68 | """ 69 | PEP 3101 supports replacement fields inside format specifications. 70 | Since it would complicate the implementation, for a feature most likely not needed, 71 | we choose not to support it. 72 | see: https://www.python.org/dev/peps/pep-3101 73 | """ 74 | for prefix, name, spec, conversion in __lexer.parse(format_spec): 75 | if name is not None: 76 | raise CommandParseError( 77 | f'Replacement fields in format specifications are not supported: {format_spec}', 78 | subject, 79 | ) 80 | 81 | 82 | def _check_conversion_spec(spec: str, subject: str): 83 | match spec: 84 | case 's' | 'r' | 'a' | 'q': 85 | ... 86 | case _: 87 | raise CommandParseError( 88 | f'Invalid conversion specifier: {spec} (expected s, r, a or q)', 89 | subject, 90 | ) 91 | -------------------------------------------------------------------------------- /docs/snippet-anatomy.md: -------------------------------------------------------------------------------- 1 | # Anatomy of a snippet 2 | 3 | A clisnips snippet has the following properties: 4 | 5 | * `title`: a title 6 | * `tag`: some tags 7 | * `cmd`: a command 8 | * `doc`: a documentation 9 | 10 | The `title` and `tag` properties are what affect search results. 11 | The `cmd` and `doc` properties affect what will end up being inserted in your terminal. 12 | We will describe these properties in the following sections. 13 | 14 | ## The `title` property 15 | 16 | The title property is, as the name suggests, a descriptive title for the snippet. 17 | The content of this property will be indexed in the snippets database and will influence the snippets search result. 18 | It should be kept short and descriptive. 19 | 20 | ## The `tag` property 21 | 22 | The tag property is a list of keywords that allows you to logically group your snippets. 23 | The content of this property will be indexed in the snippets database and will influence the snippets search result. 24 | 25 | A tag is any keyword that: 26 | * consists only of unicode alphabetical characters plus the "_" and "-" characters. 27 | Diacritics like "é" will be internally converted to their ASCII equivalent during a search. 28 | * is at least two characters long 29 | 30 | This means that tags can be separated by commas, spaces, colons, etc... 31 | For example the string `this,is a list;of|tags` is a list of tags containing the tags `this`, `is`, `list` and `tags`. 32 | 33 | ## The `cmd` property 34 | 35 | This is the actual meat of the snippet. The thing you want to insert in your terminal. 36 | 37 | The content of this property will NOT influence the snippets search result. 38 | 39 | A command can be a simple string, like `cat /etc/passwd`, 40 | but it can also contain template fields like `zip -r -9 {archive_name}.zip {input_directory}`. 41 | 42 | Template fields MUST conform to the [Python format string syntax](https://docs.python.org/3/library/string.html#format-string-syntax). 43 | 44 | If a snippet's `cmd` property contains template fields, 45 | clisnips will prompt you for a value before inserting a snippet into the terminal. 46 | 47 | ## The `doc` property 48 | 49 | The role of this property is two-fold: 50 | 1. it can be used to provide more extensive documentation on your snippet. 51 | 2. it can be used to provide contextual information about the command's template fields. 52 | 53 | The content of this property will NOT influence the snippets search result. 54 | 55 | For example, given a snippet with the following `cmd` property: 56 | ```sh 57 | zip -r -9 {archive_name}.zip {input_directory} 58 | ``` 59 | The corresponding documentation could look like this: 60 | ``` 61 | Creates a ZIP archive of a directory with maximum compression level. 62 | 63 | {archive_name} (string) The file name of the output archive 64 | {input_directory} (directory) The directory to compress 65 | ``` 66 | 67 | When requested to insert this snippet into your terminal, clisnips will parse this documentation. 68 | The first paragraph will just be treated as informative free-form text. 69 | The following lines however will be parsed as your template fields documentation. 70 | Clisnips will then use this information to provide you with a more sensible user-interface. 71 | For example, clisnips will acknowledge that the `input_directory` field should be an actual directory 72 | on your filesystem, and present you an input field with path completion enabled. 73 | -------------------------------------------------------------------------------- /clisnips/cli/app.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | import sys 4 | from typing import Any 5 | 6 | from clisnips.dic import DependencyInjectionContainer 7 | 8 | from .command import Command 9 | from .parser import LazySubParser 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class Application: 15 | commands: dict[str, dict[str, Any]] = { 16 | 'version': { 17 | 'help': 'Outputs the clisnips version number.', 18 | }, 19 | 'config': { 20 | 'help': 'Shows the current configuration.', 21 | }, 22 | 'key-bindings': { 23 | 'help': 'Installs clisnips key bindings for the given shell.', 24 | 'module': 'install_key_bindings', 25 | }, 26 | 'import': { 27 | 'help': 'Imports snippets from a file.', 28 | 'module': '_import', 29 | }, 30 | 'export': { 31 | 'help': 'Exports snippets to a file.', 32 | }, 33 | 'dump': { 34 | 'help': 'Runs a SQL dump of the database.', 35 | }, 36 | 'optimize': { 37 | 'help': 'Runs optimization tasks on the database.', 38 | }, 39 | 'logs': { 40 | 'help': 'Watch clisnips TUI logs', 41 | }, 42 | } 43 | 44 | def run(self) -> int: 45 | argv = self._parse_arguments() 46 | if cls := getattr(argv, '__command__', None): 47 | return self._run_command(cls, argv) 48 | return self._run_tui(argv) 49 | 50 | @classmethod 51 | def _parse_arguments(cls): 52 | parser = argparse.ArgumentParser( 53 | prog='clisnips', 54 | description='A command-line snippets manager.', 55 | ) 56 | parser.add_argument('--database', help='Path to an alternate SQLite database.') 57 | parser.add_argument('--log-level', choices=('debug', 'info', 'warning', 'error'), help='') 58 | sub = parser.add_subparsers( 59 | title='Subcommands', 60 | metavar='command', 61 | description='The following commands are available outside the GUI.', 62 | parser_class=LazySubParser, 63 | ) 64 | for name, kwargs in cls.commands.items(): 65 | module = kwargs.pop('module', name) 66 | p = sub.add_parser(name, **kwargs) 67 | p.set_defaults(__module__=module) 68 | 69 | return parser.parse_args() 70 | 71 | def _run_command(self, cls: type[Command], argv) -> int: 72 | from clisnips.log.cli import configure as configure_logging 73 | 74 | dic = self._create_container(argv) 75 | configure_logging(dic.markup_helper, argv.log_level or 'info', sys.stderr) 76 | 77 | logger.debug('launching command: %s', argv) 78 | command = cls(dic) 79 | try: 80 | return command.run(argv) 81 | except Exception as err: 82 | logger.exception(err) 83 | return 128 84 | 85 | def _run_tui(self, argv) -> int: 86 | from clisnips.log.tui import configure as configure_logging 87 | from clisnips.tui.app import Application 88 | 89 | dic = self._create_container(argv) 90 | configure_logging(dic.config.log_file, argv.log_level) 91 | 92 | logging.getLogger(__name__).info('launching TUI') 93 | app = Application(dic) 94 | return app.run() 95 | 96 | @staticmethod 97 | def _create_container(argv) -> DependencyInjectionContainer: 98 | return DependencyInjectionContainer(database=argv.database) 99 | -------------------------------------------------------------------------------- /clisnips/tui/widgets/combobox.py: -------------------------------------------------------------------------------- 1 | import enum 2 | from collections.abc import Iterable 3 | from typing import Generic, TypeVar 4 | 5 | import urwid 6 | 7 | from .menu import PopupMenu 8 | 9 | __all__ = ['ComboBox'] 10 | 11 | from .radio import RadioItem, RadioState 12 | 13 | V = TypeVar('V') 14 | 15 | 16 | class Select(PopupMenu, Generic[V]): 17 | class Signals(enum.StrEnum): 18 | CHANGED = enum.auto() 19 | 20 | signals = PopupMenu.signals + list(Signals) 21 | 22 | def __init__(self): 23 | self._group: list[RadioItem[V]] = [] 24 | self._selected_item: RadioItem[V] | None = None 25 | super().__init__() 26 | 27 | def set_choices(self, choices: Iterable[tuple[str, V, bool]]): 28 | self._walker.clear() 29 | for choice in choices: 30 | self.append_choice(*choice) 31 | 32 | def append_choice(self, label: str, value: V, selected: bool = False): 33 | item = RadioItem(self._group, label, value, selected) 34 | urwid.connect_signal(item, 'change', self._on_item_changed) 35 | if selected: 36 | self._selected_item = item 37 | super().append(item) 38 | 39 | def get_selected(self) -> RadioItem[V] | None: 40 | return self._selected_item 41 | 42 | def get_default(self) -> RadioItem[V] | None: 43 | if not len(self): 44 | return None 45 | for item, _ in self._walker: 46 | if item.get_state() is True: 47 | return item 48 | return self._walker[0] 49 | 50 | def _on_item_changed(self, item: RadioItem[V], state: RadioState): 51 | if state is True: 52 | self._selected_item = item 53 | self._emit(self.Signals.CHANGED, item) 54 | 55 | 56 | class ComboBoxButton(urwid.Button): 57 | button_left = urwid.Text('▼') 58 | button_right = urwid.Text('▼') 59 | 60 | 61 | class ComboBox(urwid.PopUpLauncher, Generic[V]): 62 | class Signals(enum.StrEnum): 63 | CHANGED = 'changed' 64 | 65 | signals = list(Signals) 66 | 67 | def __init__(self): 68 | self._select: Select[V] = Select() 69 | urwid.connect_signal(self._select, 'closed', lambda *x: self.close_pop_up()) 70 | urwid.connect_signal(self._select, 'changed', self._on_selection_changed) 71 | 72 | self._button = ComboBoxButton('Select an item') 73 | 74 | super().__init__(self._button) 75 | urwid.connect_signal(self.original_widget, 'click', lambda *x: self.open_pop_up()) 76 | 77 | def set_items(self, items): 78 | self._select.set_choices(items) 79 | default = self._select.get_default() 80 | if default: 81 | self._button.set_label(default.get_label()) 82 | 83 | def append(self, label: str, value: V, selected=False): 84 | self._select.append_choice(label, value, selected) 85 | if selected: 86 | self._button.set_label(label) 87 | 88 | def create_pop_up(self) -> Select[V]: 89 | return self._select 90 | 91 | def get_pop_up_parameters(self): 92 | return {'left': 0, 'top': 0, 'overlay_width': 32, 'overlay_height': len(self._select) + 2} 93 | 94 | def get_selected(self) -> V | None: 95 | item = self._select.get_selected() 96 | if item is not None: 97 | return item.get_value() 98 | 99 | def _on_selection_changed(self, menu, item: RadioItem[V]): 100 | self._button.set_label(item.get_label()) 101 | self.close_pop_up() 102 | self._emit(self.Signals.CHANGED, item.get_value()) 103 | -------------------------------------------------------------------------------- /clisnips/cli/commands/logs.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import asyncio 3 | import logging 4 | import pickle 5 | import struct 6 | import sys 7 | from logging import LogRecord 8 | from pathlib import Path 9 | 10 | from clisnips.cli.command import Command 11 | from ..utils import UrwidMarkupHelper 12 | 13 | 14 | def configure(_: argparse.ArgumentParser): 15 | return LogsCommand 16 | 17 | 18 | class LogsCommand(Command): 19 | @classmethod 20 | def configure(cls, action: argparse._SubParsersAction): 21 | action.add_parser('logs', help='Watch clisnips logs') 22 | 23 | def run(self, argv) -> int: 24 | log_file = self.container.config.log_file 25 | 26 | async def serve(): 27 | server = Server(log_file, self.container.markup_helper) 28 | task = asyncio.create_task(server.start()) 29 | await asyncio.gather(task) 30 | 31 | try: 32 | asyncio.run(serve(), debug=False) 33 | except (KeyboardInterrupt, SystemExit): 34 | ... 35 | 36 | return 0 37 | 38 | 39 | class Server: 40 | def __init__(self, log_file: Path, printer: UrwidMarkupHelper) -> None: 41 | self._log_file = log_file 42 | self._formatter = RecordFormatter(printer) 43 | self._print = printer.print 44 | 45 | async def start(self): 46 | self._print(('info', f'>>> starting log server on {self._log_file}')) 47 | server = await asyncio.start_unix_server(self._client_connected, self._log_file) 48 | async with server: 49 | await server.serve_forever() 50 | 51 | async def _client_connected(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter): 52 | self._print(('success', '>>> client logger connected')) 53 | try: 54 | while record := await self._read_record(reader): 55 | self._handle_record(record) 56 | self._print(('info', '>>> client logger disconnected')) 57 | finally: 58 | writer.close() 59 | 60 | async def _read_record(self, reader: asyncio.StreamReader) -> logging.LogRecord | None: 61 | header = await reader.read(4) 62 | if not header: 63 | return 64 | record_len, *_ = struct.unpack('>L', header) 65 | buf = await reader.readexactly(record_len) 66 | return logging.makeLogRecord(pickle.loads(buf)) 67 | 68 | def _handle_record(self, record: logging.LogRecord): 69 | print(self._formatter.format(record)) 70 | 71 | 72 | class RecordFormatter(logging.Formatter): 73 | levels = { 74 | 'DEBUG': 'debug', 75 | 'INFO': 'info', 76 | 'WARNING': 'warning', 77 | 'ERROR': 'error', 78 | 'CRITICAL': 'error', 79 | } 80 | 81 | def __init__(self, printer: UrwidMarkupHelper) -> None: 82 | self._printer = printer 83 | super().__init__() 84 | 85 | def formatMessage(self, record: LogRecord) -> str: 86 | spec = self.levels.get(record.levelname, 'default') 87 | markup = [ 88 | (spec, f'{record.levelname} '), 89 | ('default', '['), 90 | ('accent', record.name), 91 | ('default', ':'), 92 | ('debug', record.funcName), 93 | ('default', ':'), 94 | ('debug', str(record.lineno)), 95 | ('default', '] '), 96 | ('default', record.message), 97 | ] 98 | return self._printer.convert_markup(markup, sys.stdout.isatty()) 99 | 100 | def _call_site(self, record: LogRecord): 101 | return ( 102 | ('default', '['), 103 | ('debug', record.funcName), 104 | ('default', ':'), 105 | ('debug', str(record.lineno)), 106 | ('default', ']'), 107 | ) 108 | -------------------------------------------------------------------------------- /docs/creating-snippets.md: -------------------------------------------------------------------------------- 1 | # Creating snippets 2 | 3 | ## I. Simple snippets 4 | 5 | First open clisnips by invoking the keyboard shortcut (`alt+s` by default). 6 | 7 | Then follow these steps: 8 | 1. put the focus on the snippets list by pressing your `tab` key 9 | 2. invoke the snippet creation dialog by pressing `+`. 10 | 3. fill the snippets properties, using your `up` or `down` arrow keys to move focus between the input fields. 11 | 4. When finished, focus the `< Apply >` button and press `enter`. 12 | 13 | ## II. Parametrized snippets 14 | 15 | Creating parametrized snippets is what clisnips is really about, 16 | allowing the creation of truly reusable command templates. 17 | 18 | ### A simple example 19 | 20 | Let's start with a simple example. 21 | 22 | Follow the steps 1 to 4 from section I. above, using the following values for step 3: 23 | 24 | | Field | Value | 25 | |---------------|--------------------| 26 | | Title | Example n°1 | 27 | | Tags | tutorial | 28 | | Command | `mv {} {}` | 29 | | Documentation | Moves stuff around | 30 | 31 | Notice that the value for our `command` field contains to template fields. 32 | 33 | Back to the snippet list, find your new snippet by searching for `example` or `tutorial`, 34 | focus it and press `enter`. 35 | 36 | Clisnips now recognizes that the snippet is parametrized and opens a dialog 37 | to prompt you for the two values corresponding to the template fields we provided for the command. 38 | 39 | Since we did not provide a name for the fields they are implicitly numbered `0` and `1`. 40 | 41 | If you fill-in the input fields with the values `foo` and `bar` and press the `< Apply >` button, 42 | clisnips should now have inserted the command `mv foo bar` in your terminal. 43 | 44 | Congratulations, you just created your first parametrized snippet ! 45 | 46 | ### Going further 47 | 48 | There is more you can do with parametrized snippets, 49 | but before going further you may want to read about 50 | [a snippet's anatomy][snippet-anatomy]. 51 | 52 | #### Field naming 53 | 54 | Instead of relying on the implicit field numbering as in the above example, 55 | you can (and probably should) give a descriptive name to your template fields, 56 | for example `mv {source} {destination}`. 57 | 58 | #### Field syntax 59 | 60 | The template fields in commands follow the [Python format string syntax](https://docs.python.org/3/library/string.html#format-string-syntax). 61 | 62 | Since clisnips is written in python, this means you can use all the formatting options of the aforementioned syntax 63 | (i.e. using `{: >4}` to pad a string with up-to 4 space characters). 64 | 65 | This also means that to include a literal curly-brace character (`{` or `}`) in your command, 66 | you have to escape it by doubling it using `{{` or `}}`. 67 | 68 | #### Flag fields 69 | 70 | Since clisnips was made to deal mostly with CLI snippets, it extends the python format string syntax 71 | to allow for one or two leading `-` characters in template field names. 72 | 73 | When provided with such a field name, clisnips will treat it as a boolean CLI flag. 74 | 75 | For example given the command `ls {-l}`, clisnips will prompt you with a checkbox, 76 | and output either `ls -l` if you check it, or just `ls` if it don't. 77 | 78 | 79 | #### Field types 80 | 81 | By default, template fields are treated as simple strings (or boolean flags as we've just seen). 82 | 83 | There are other special field types you can use like numbers, filesystem paths, ranges, choice lists... 84 | To use these special types, you'll have to write proper documentation for your snippet. 85 | 86 | These are documented in the following section: [Snippet documentation][snippet-documentation]. 87 | 88 | 89 | [snippet-anatomy]: https://github.com/ju1ius/clisnips/blob/master/doc/snippet-anatomy.md 90 | [snippet-documentation]: https://github.com/ju1ius/clisnips/blob/master/doc/snippet-documentation.md 91 | -------------------------------------------------------------------------------- /tests/database/offset_pager_test.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | from pathlib import Path 3 | 4 | import pytest 5 | 6 | from clisnips.database.offset_pager import OffsetPager 7 | 8 | FIXTURES = [ 9 | ('test foo', 5), 10 | ('test bar', 6), 11 | ('test baz', 3), 12 | ('test qux', 4), 13 | ('test foobar', 8), 14 | ] 15 | 16 | 17 | @pytest.fixture(scope='module') 18 | def connection(): 19 | con = sqlite3.connect(':memory:') 20 | con.row_factory = sqlite3.Row 21 | with open(Path(__file__).parent / 'pager_test.schema.sql') as fp: 22 | con.executescript(fp.read()) 23 | rowid = 0 24 | for i in range(4): 25 | for value, ranking in FIXTURES: 26 | rowid += 1 27 | value = f'{value} #{rowid}' 28 | con.execute( 29 | """ 30 | insert into paging_test(rowid, value, ranking) 31 | values(?, ?, ?) 32 | """, 33 | (rowid, value, ranking), 34 | ) 35 | yield con 36 | con.close() 37 | 38 | 39 | def rowids(rows) -> list[int]: 40 | return [r['rowid'] for r in rows] 41 | 42 | 43 | def test_simple_query(connection): 44 | pager = OffsetPager(connection, 5) 45 | pager.set_query('select rowid, * from paging_test') 46 | pager.execute() 47 | assert len(pager) == 4, 'Wrong number of pages.' 48 | expected = { 49 | 1: [1, 2, 3, 4, 5], 50 | 2: [6, 7, 8, 9, 10], 51 | 3: [11, 12, 13, 14, 15], 52 | 4: [16, 17, 18, 19, 20], 53 | } 54 | # 55 | first = pager.first() 56 | assert rowids(first) == expected[1], 'Failed fetching first page.' 57 | # 58 | last = pager.last() 59 | assert rowids(last) == expected[4], 'Failed fetching last page.' 60 | # 61 | page_2 = pager.get_page(2) 62 | assert rowids(page_2) == expected[2], 'Failed fetching page 2.' 63 | # 64 | page_3 = pager.next() 65 | assert rowids(page_3) == expected[3], 'Failed fetching next page (3).' 66 | # 67 | rs = pager.previous() 68 | assert rowids(rs) == expected[2], 'Failed fetching previous page (2).' 69 | # 70 | last = pager.last() 71 | assert pager.next() == last, 'Calling next on last page returns last page' 72 | first = pager.first() 73 | assert pager.previous() == first, 'Calling previous on first page returns first page' 74 | 75 | 76 | def test_complex_query(connection): 77 | pager = OffsetPager(connection, 5) 78 | params = {'term': 'foo*'} 79 | expected = { 80 | 1: [1, 5, 6, 10, 11], 81 | 2: [15, 16, 20], 82 | } 83 | pager.set_query( 84 | """ 85 | SELECT i.docid, t.rowid, t.* FROM paging_test t 86 | JOIN paging_test_idx i ON i.docid = t.rowid 87 | WHERE paging_test_idx MATCH :term 88 | """, 89 | params, 90 | ) 91 | pager.set_count_query( 92 | """ 93 | SELECT docid FROM paging_test_idx 94 | WHERE paging_test_idx MATCH :term 95 | """, 96 | params, 97 | ) 98 | pager.execute() 99 | assert len(pager) == 2, 'Wrong number of pages.' 100 | # 101 | first = pager.first() 102 | assert rowids(first) == expected[1], 'Failed fetching first page.' 103 | # 104 | last = pager.next() 105 | assert rowids(last) == expected[2], 'Failed fetching next page (2).' 106 | # 107 | first = pager.previous() 108 | assert rowids(first) == expected[1], 'Failed fetching previous page (1).' 109 | # 110 | last = pager.last() 111 | assert rowids(last) == expected[2], 'Failed fetching last page.' 112 | # 113 | page_2 = pager.get_page(2) 114 | assert rowids(page_2) == expected[2], 'Failed fetching page 2.' 115 | # 116 | last = pager.last() 117 | assert pager.next() == last, 'Calling next on last page returns last page' 118 | first = pager.first() 119 | assert pager.previous() == first, 'Calling previous on first page returns first page' 120 | -------------------------------------------------------------------------------- /clisnips/tui/widgets/field/path.py: -------------------------------------------------------------------------------- 1 | import enum 2 | from collections.abc import Iterable 3 | 4 | import urwid 5 | 6 | from clisnips.tui.urwid_types import TextMarkup 7 | from clisnips.tui.widgets.edit import EmacsEdit 8 | from clisnips.tui.widgets.menu import PopupMenu 9 | from clisnips.ty import AnyPath 10 | from clisnips.utils.path_completion import FileSystemPathCompletionProvider, PathCompletion, PathCompletionEntry 11 | 12 | from .field import Entry, SimpleField 13 | 14 | 15 | class PathField(SimpleField[str]): 16 | def __init__(self, label: TextMarkup, *args, **kwargs): 17 | entry = PathEntry(*args, **kwargs) 18 | super().__init__(label, entry) 19 | 20 | 21 | class PathEntry(Entry[str], urwid.PopUpLauncher): 22 | signals = ['changed'] 23 | 24 | def __init__(self, cwd: AnyPath = '.', mode: str = '', default: str = ''): 25 | provider = FileSystemPathCompletionProvider(cwd) 26 | self._completion = PathCompletion(provider, show_files=mode != 'dir') 27 | 28 | self._menu = PathCompletionMenu() 29 | urwid.connect_signal(self._menu, 'closed', lambda *x: self.close_pop_up()) 30 | urwid.connect_signal(self._menu, self._menu.Signals.COMPLETION_SELECTED, self._on_completion_selected) 31 | 32 | self._entry = EmacsEdit('', default) 33 | self._entry.keypress = self._on_entry_key_pressed 34 | urwid.connect_signal(self._entry, 'postchange', lambda *x: self._emit('changed')) 35 | 36 | super().__init__(self._entry) 37 | 38 | def get_value(self) -> str: 39 | return self._entry.get_edit_text() 40 | 41 | def create_pop_up(self): 42 | return self._menu 43 | 44 | def get_pop_up_parameters(self): 45 | return {'left': 0, 'top': 1, 'overlay_width': 40, 'overlay_height': 20} 46 | 47 | def _trigger_completion(self): 48 | text = self._entry.get_edit_text() 49 | try: 50 | completions = self._completion.get_completions(text) 51 | except FileNotFoundError: 52 | return 53 | if not completions: 54 | return 55 | if len(completions) == 1: 56 | self._insert_completion(completions[0]) 57 | return 58 | self._menu.set_completions(completions) 59 | self.open_pop_up() 60 | 61 | def _on_entry_key_pressed(self, size, key): 62 | if key == 'tab': 63 | self._trigger_completion() 64 | return 65 | return EmacsEdit.keypress(self._entry, size, key) 66 | 67 | def _on_completion_selected(self, menu, entry: PathCompletionEntry): 68 | self._insert_completion(entry) 69 | self.close_pop_up() 70 | 71 | def _insert_completion(self, entry: PathCompletionEntry): 72 | text = self._completion.complete(self._entry.get_edit_text(), entry) 73 | self._entry.set_edit_text(text) 74 | self._entry.set_edit_pos(len(text)) 75 | 76 | 77 | class PathCompletionMenu(PopupMenu): 78 | class Signals(enum.StrEnum): 79 | COMPLETION_SELECTED = enum.auto() 80 | 81 | signals = PopupMenu.signals + list(Signals) 82 | 83 | def set_completions(self, completions: Iterable[PathCompletionEntry]): 84 | items = [] 85 | for entry in completions: 86 | item = PathCompletionMenuItem(entry) 87 | urwid.connect_signal(item, 'click', self._on_item_clicked, user_args=(entry,)) 88 | items.append(item) 89 | self._walker[:] = items 90 | self._walker.set_focus(0) 91 | 92 | def _on_item_clicked(self, entry: PathCompletionEntry, button): 93 | self._emit(self.Signals.COMPLETION_SELECTED, entry) 94 | 95 | 96 | class PathCompletionMenuItem(urwid.Button): 97 | button_left = urwid.Text('') 98 | button_right = urwid.Text('') 99 | 100 | def __init__(self, entry: PathCompletionEntry): 101 | prefix = 'symlink-' if entry.is_link else '' 102 | file_type = 'directory' if entry.is_dir else 'file' 103 | label = (f'path-completion:{prefix}{file_type}', entry.display_name) 104 | super().__init__(label) 105 | -------------------------------------------------------------------------------- /clisnips/database/search_pager.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sqlite3 4 | from collections.abc import Iterable 5 | from contextlib import contextmanager 6 | from typing import TYPE_CHECKING, Self 7 | 8 | from . import Snippet, SortColumn, SortOrder 9 | from .scrolling_pager import ScrollingPager, SortColumnDefinition 10 | from .snippets_db import QueryParameters, SnippetsDatabase 11 | 12 | 13 | class SearchSyntaxError(RuntimeError): 14 | pass 15 | 16 | 17 | # There's no way to declare a proxy type with the current type-checkers 18 | # so we extend 19 | class SearchPager(ScrollingPager[Snippet] if TYPE_CHECKING else object): 20 | def __init__( 21 | self, 22 | db: SnippetsDatabase, 23 | sort_column: tuple[SortColumn, SortOrder] = (SortColumn.RANKING, SortOrder.DESC), 24 | page_size: int = 50, 25 | ): 26 | self._page_size = page_size 27 | self._list_pager: ScrollingPager[Snippet] = ScrollingPager(db.connection, page_size) 28 | self._list_pager.set_query(db.get_listing_query()) 29 | self._list_pager.set_count_query(db.get_listing_count_query()) 30 | 31 | self._search_pager: ScrollingPager[Snippet] = ScrollingPager(db.connection, page_size) 32 | self._search_pager.set_query(db.get_search_query()) 33 | self._search_pager.set_count_query(db.get_search_count_query()) 34 | 35 | self._is_searching = False 36 | self._current_pager = self._list_pager 37 | self.set_sort_column(*sort_column) 38 | 39 | def __getattr__(self, attr: str): 40 | return getattr(self._current_pager, attr) 41 | 42 | @property 43 | def is_searching(self) -> bool: 44 | return self._is_searching 45 | 46 | @property 47 | def page_size(self) -> int: 48 | return self._page_size 49 | 50 | @page_size.setter 51 | def page_size(self, size: int): 52 | self.set_page_size(size) 53 | 54 | def search(self, term: str): 55 | self._is_searching = True 56 | self._current_pager = self._search_pager 57 | params = {'term': term} 58 | self.execute(params, params) 59 | return self.first() 60 | 61 | def list(self): 62 | self._is_searching = False 63 | self._current_pager = self._list_pager 64 | return self.execute().first() 65 | 66 | def set_sort_column(self, column: SortColumn, order: SortOrder = SortOrder.DESC): 67 | self.set_sort_columns( 68 | ( 69 | (column, order), 70 | ('id', SortOrder.ASC, True), 71 | ) 72 | ) 73 | 74 | def set_sort_columns(self, columns: Iterable[SortColumnDefinition]): 75 | self._list_pager.set_sort_columns(columns) 76 | self._search_pager.set_sort_columns(columns) 77 | 78 | def set_page_size(self, size: int): 79 | self._page_size = size 80 | self._list_pager.set_page_size(size) 81 | self._search_pager.set_page_size(size) 82 | 83 | def execute(self, params: QueryParameters = (), count_params: QueryParameters = ()) -> Self: 84 | with self._convert_exceptions(): 85 | self._current_pager.execute(params, count_params) 86 | return self 87 | 88 | def count(self): 89 | with self._convert_exceptions(): 90 | self._current_pager.count() 91 | 92 | def __len__(self): 93 | return len(self._current_pager) 94 | 95 | @contextmanager 96 | def _convert_exceptions(self): 97 | try: 98 | yield 99 | except sqlite3.OperationalError as err: 100 | if self._is_searching and _is_search_syntax_error(err): 101 | raise SearchSyntaxError(*err.args) from err 102 | raise err 103 | 104 | 105 | def _is_search_syntax_error(err: sqlite3.OperationalError) -> bool: 106 | if not err.args: 107 | return False 108 | msg = str(err.args[0]) 109 | return ( 110 | msg.startswith('fts5: syntax error') 111 | or msg.startswith('no such column') 112 | or msg.startswith('unknown special query:') 113 | ) 114 | -------------------------------------------------------------------------------- /tests/database/scroll_cursor_test.py: -------------------------------------------------------------------------------- 1 | from clisnips.database import ScrollDirection, SortOrder 2 | from clisnips.database.scrolling_pager import Cursor 3 | 4 | 5 | def test_default_columns(): 6 | cursor = Cursor() 7 | assert cursor.unique_column == ('rowid', SortOrder.ASC) 8 | assert cursor.sort_columns == [] 9 | assert list(cursor.columns()) == [('rowid', SortOrder.ASC)] 10 | 11 | order_by = cursor.as_order_by_clause(ScrollDirection.FWD) 12 | assert order_by == 'rowid ASC' 13 | 14 | order_by = cursor.as_order_by_clause(ScrollDirection.BWD) 15 | assert order_by == 'rowid DESC' 16 | 17 | where = cursor.as_where_clause(ScrollDirection.FWD) 18 | assert where == 'rowid > {cursor.last[rowid]!r}' 19 | 20 | where = cursor.as_where_clause(ScrollDirection.BWD) 21 | assert where == 'rowid < {cursor.first[rowid]!r}' 22 | 23 | 24 | def test_single_sort_column(): 25 | cursor = Cursor.with_columns( 26 | ( 27 | ('foo', SortOrder.DESC), 28 | ('id', SortOrder.ASC, True), 29 | ) 30 | ) 31 | assert cursor.unique_column == ('id', SortOrder.ASC) 32 | assert cursor.sort_columns == [('foo', SortOrder.DESC)] 33 | assert list(cursor.columns()) == [ 34 | ('foo', SortOrder.DESC), 35 | ('id', SortOrder.ASC), 36 | ] 37 | 38 | order_by = cursor.as_order_by_clause(ScrollDirection.FWD) 39 | assert order_by == 'foo DESC, id ASC' 40 | 41 | order_by = cursor.as_order_by_clause(ScrollDirection.BWD) 42 | assert order_by == 'foo ASC, id DESC' 43 | 44 | where = cursor.as_where_clause(ScrollDirection.FWD) 45 | assert where == '(foo <= {cursor.last[foo]!r}) AND (foo < {cursor.last[foo]!r} OR id > {cursor.last[id]!r})' 46 | 47 | where = cursor.as_where_clause(ScrollDirection.BWD) 48 | assert where == '(foo >= {cursor.first[foo]!r}) AND (foo > {cursor.first[foo]!r} OR id < {cursor.first[id]!r})' 49 | 50 | 51 | def test_multiple_sort_columns(): 52 | cursor = Cursor.with_columns( 53 | ( 54 | ('foo', SortOrder.DESC), 55 | ('id', SortOrder.ASC, True), 56 | ('bar', SortOrder.ASC), 57 | ) 58 | ) 59 | assert cursor.unique_column == ('id', SortOrder.ASC) 60 | assert cursor.sort_columns == [('foo', SortOrder.DESC), ('bar', SortOrder.ASC)] 61 | assert list(cursor.columns()) == [ 62 | ('foo', SortOrder.DESC), 63 | ('bar', SortOrder.ASC), 64 | ('id', SortOrder.ASC), 65 | ] 66 | 67 | order_by = cursor.as_order_by_clause(ScrollDirection.FWD) 68 | assert order_by == 'foo DESC, bar ASC, id ASC' 69 | 70 | order_by = cursor.as_order_by_clause(ScrollDirection.BWD) 71 | assert order_by == 'foo ASC, bar DESC, id DESC' 72 | 73 | where = cursor.as_where_clause(ScrollDirection.FWD) 74 | assert where == ( 75 | '(foo <= {cursor.last[foo]!r} AND bar >= {cursor.last[bar]!r})' 76 | ' AND (foo < {cursor.last[foo]!r} OR bar > {cursor.last[bar]!r} OR id > {cursor.last[id]!r})' 77 | ) 78 | 79 | where = cursor.as_where_clause(ScrollDirection.BWD) 80 | assert where == ( 81 | '(foo >= {cursor.first[foo]!r} AND bar <= {cursor.first[bar]!r})' 82 | ' AND (foo > {cursor.first[foo]!r} OR bar < {cursor.first[bar]!r} OR id < {cursor.first[id]!r})' 83 | ) 84 | 85 | 86 | def test_update(): 87 | cursor = Cursor.with_columns( 88 | ( 89 | ('foo', SortOrder.DESC), 90 | ('bar', SortOrder.ASC), 91 | ('id', SortOrder.ASC, True), 92 | ) 93 | ) 94 | empty = {'id': None, 'foo': None, 'bar': None} 95 | assert cursor.first == cursor.last == empty 96 | 97 | rs = [ 98 | {'id': 0, 'foo': 666, 'bar': 'first', 'baz': 'nope'}, 99 | {'id': 1, 'foo': 333, 'bar': 'ignoreme', 'baz': 'nada'}, 100 | {'id': 2, 'foo': 111, 'bar': 'last', 'baz': 'zilch'}, 101 | ] 102 | cursor.update(rs) 103 | assert cursor.first == {'id': 0, 'foo': 666, 'bar': 'first'} 104 | assert cursor.last == {'id': 2, 'foo': 111, 'bar': 'last'} 105 | 106 | cursor.update([]) 107 | assert cursor.first == cursor.last == empty 108 | 109 | cursor.update(rs[0:1]) 110 | assert cursor.first == cursor.last == {'id': 0, 'foo': 666, 'bar': 'first'} 111 | -------------------------------------------------------------------------------- /clisnips/tui/widgets/dialog.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import enum 4 | from typing import TYPE_CHECKING 5 | 6 | import urwid 7 | from urwid.widget.constants import Align, VAlign, WHSettings 8 | 9 | if TYPE_CHECKING: 10 | from clisnips.tui.view import View 11 | 12 | from .divider import HorizontalDivider 13 | 14 | 15 | class ResponseKind(enum.Enum): 16 | NONE = 0 17 | ACCEPT = 1 18 | REJECT = 2 19 | 20 | 21 | class Action(urwid.WidgetWrap): 22 | class Signals(enum.StrEnum): 23 | ACTIVATED = enum.auto() 24 | 25 | signals = list(Signals) 26 | 27 | class Kind(enum.StrEnum): 28 | DEFAULT = enum.auto() 29 | SUGGESTED = enum.auto() 30 | DESTRUCTIVE = enum.auto() 31 | 32 | def __init__(self, label: str, response_kind: ResponseKind, kind: Kind = Kind.DEFAULT): 33 | self._response_kind = response_kind 34 | self._kind = kind 35 | self._enabled = True 36 | 37 | self._button = urwid.Button(label) 38 | self._attrs = urwid.AttrMap(self._button, self._get_attr_map()) 39 | urwid.connect_signal(self._button, 'click', self._on_activated) 40 | 41 | super().__init__(self._attrs) 42 | 43 | def enable(self): 44 | self.toggle(True) 45 | 46 | def disable(self): 47 | self.toggle(False) 48 | 49 | def toggle(self, enabled: bool): 50 | self._enabled = enabled 51 | self._attrs.attr_map = self._get_attr_map() 52 | self._attrs.focus_map = self._get_attr_map() 53 | 54 | def _on_activated(self, btn): 55 | if self._enabled: 56 | self._emit(Action.Signals.ACTIVATED, self._response_kind) 57 | 58 | def _get_attr_map(self): 59 | if not self._enabled: 60 | return {None: 'action:disabled'} 61 | return {None: f'action:{self._kind}'} 62 | 63 | 64 | class DialogFrame(urwid.WidgetWrap): 65 | def __init__(self, parent: urwid.Widget, body: Dialog, title: str): 66 | self.parent = parent 67 | self.line_box = urwid.LineBox(body, title=title) 68 | super().__init__(self.line_box) 69 | 70 | 71 | class DialogOverlay(urwid.Overlay): 72 | def __init__(self, parent: urwid.Widget, *args, **kwargs): 73 | self.parent = parent 74 | super().__init__(*args, **kwargs) 75 | 76 | 77 | class Dialog(urwid.WidgetWrap): 78 | class Signals(enum.StrEnum): 79 | RESPONSE = 'response' 80 | 81 | signals = list(Signals) 82 | 83 | Action = Action 84 | 85 | def __init__(self, view: View, body: urwid.Widget): 86 | self._parent_view = view 87 | self._body = body 88 | self._action_area = urwid.GridFlow((), 1, 3, 1, 'center') 89 | self._frame = urwid.Pile([self._body]) 90 | w = self._frame 91 | # pad area around listbox 92 | w = urwid.Padding(w, align=Align.LEFT, left=2, right=2, width=(WHSettings.RELATIVE, 100)) 93 | w = urwid.Filler(w, valign=VAlign.TOP, top=1, bottom=1, height=('relative', 100)) 94 | w = urwid.AttrMap(w, 'dialog') 95 | super().__init__(w) 96 | 97 | def close(self): 98 | self._parent_view.close_dialog() 99 | 100 | def keypress(self, size, key): 101 | match key: 102 | case 'esc': 103 | self.close() 104 | case _: 105 | super().keypress(size, key) 106 | 107 | def set_actions(self, *actions: Action): 108 | cell_width = 0 109 | for action in actions: 110 | cell_width = max(cell_width, len(action._button.label)) 111 | urwid.connect_signal(action, Action.Signals.ACTIVATED, self._on_action_activated) 112 | cell_width += 4 # account for urwid internal button decorations 113 | self._action_area = urwid.GridFlow(actions, cell_width=cell_width, h_sep=3, v_sep=1, align=Align.CENTER) 114 | footer = urwid.Pile([HorizontalDivider(), self._action_area], focus_item=1) 115 | self._frame.contents = [ 116 | (self._body, ('weight', 1)), 117 | (footer, ('pack', None)), 118 | ] 119 | 120 | def _on_action_activated(self, action: Action, response_kind: ResponseKind): 121 | # TODO: pass the action to signal handler 122 | self._emit(self.Signals.RESPONSE, response_kind) 123 | -------------------------------------------------------------------------------- /clisnips/database/offset_pager.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | from math import ceil 3 | from typing import Generic, Self 4 | 5 | from clisnips.database.snippets_db import QueryParameters 6 | 7 | from .pager import Page, Row 8 | 9 | 10 | class OffsetPager(Generic[Row]): 11 | def __init__(self, connection: sqlite3.Connection, page_size: int = 100): 12 | self._con = connection 13 | self._current_page: int = 1 14 | self._page_count: int = 1 15 | self._page_size: int = page_size 16 | self._total_size = 0 17 | self._query = '' 18 | self._query_params = () 19 | self._count_query = '' 20 | self._count_query_params = () 21 | 22 | self._executed = False 23 | 24 | def __len__(self) -> int: 25 | return self._page_count 26 | 27 | @property 28 | def page_size(self) -> int: 29 | return self._page_size 30 | 31 | @property 32 | def page_count(self) -> int: 33 | return self._page_count 34 | 35 | @property 36 | def current_page(self) -> int: 37 | self._check_executed() 38 | return self._current_page 39 | 40 | @property 41 | def is_first_page(self) -> bool: 42 | self._check_executed() 43 | return self._current_page == 1 44 | 45 | @property 46 | def is_last_page(self) -> bool: 47 | self._check_executed() 48 | return self._current_page == self._page_count 49 | 50 | @property 51 | def must_paginate(self) -> bool: 52 | self._check_executed() 53 | return self._page_count > 1 54 | 55 | @property 56 | def total_rows(self) -> int: 57 | return self._total_size 58 | 59 | def set_query(self, query: str, params: QueryParameters = ()): 60 | self._executed = False 61 | self._query = query 62 | self._query_params = params 63 | 64 | def get_query(self) -> str: 65 | return self._query 66 | 67 | def set_count_query(self, query: str, params: QueryParameters = ()): 68 | self._executed = False 69 | self._count_query = f'SELECT COUNT(*) FROM ({query})' 70 | self._count_query_params = params 71 | 72 | def set_page_size(self, size: int): 73 | self._executed = False 74 | self._page_size = size 75 | 76 | def execute(self, params: QueryParameters = (), count_params: QueryParameters = ()) -> Self: 77 | if not self._query: 78 | raise RuntimeError('You must call set_query before execute') 79 | if params: 80 | self._query_params = params 81 | if count_params: 82 | self._count_query_params = count_params 83 | self.count() 84 | self._executed = True 85 | return self 86 | 87 | def get_page(self, page: int) -> Page[Row]: 88 | self._check_executed() 89 | if page <= 1: 90 | page = 1 91 | elif page >= self._page_count: 92 | page = self._page_count 93 | self._current_page = page 94 | offset = (page - 1) * self._page_size 95 | query = 'SELECT * FROM ({query}) LIMIT {page_size} {offset}'.format( 96 | query=self._query, 97 | page_size=self._page_size, 98 | offset='' if page == 1 else f'OFFSET {offset}', 99 | ) 100 | cursor = self._con.execute(query, self._query_params) 101 | return cursor.fetchall() 102 | 103 | def first(self) -> Page[Row]: 104 | return self.get_page(1) 105 | 106 | def last(self) -> Page[Row]: 107 | return self.get_page(self._page_count) 108 | 109 | def next(self) -> Page[Row]: 110 | return self.get_page(self._current_page + 1) 111 | 112 | def previous(self) -> Page[Row]: 113 | return self.get_page(self._current_page - 1) 114 | 115 | def count(self): 116 | if not self._count_query: 117 | query = f'SELECT COUNT(*) FROM ({self._query})' 118 | params = self._query_params 119 | else: 120 | query = self._count_query 121 | params = self._count_query_params 122 | count = self._con.execute(query, params).fetchone()[0] 123 | self._total_size = count 124 | self._page_count = int(ceil(count / self._page_size)) 125 | 126 | def _check_executed(self): 127 | if not self._executed: 128 | raise RuntimeError('You must execute the pager first.') 129 | -------------------------------------------------------------------------------- /clisnips/syntax/command/renderer.py: -------------------------------------------------------------------------------- 1 | import shlex 2 | from collections.abc import Iterable, Mapping, Sequence 3 | from decimal import Decimal 4 | from string import Formatter as _StringFormatter 5 | from typing import Any, TypeAlias 6 | 7 | from clisnips.tui.urwid_types import TextMarkup 8 | from clisnips.utils.number import is_integer_decimal 9 | 10 | from .err import InterpolationError, InterpolationErrorGroup, InvalidContext 11 | from .nodes import CommandTemplate, Field, Node, Text 12 | 13 | Context: TypeAlias = Mapping[str, object] 14 | 15 | 16 | class Renderer: 17 | def __init__(self) -> None: 18 | self._formatter = _CommandFormatter() 19 | 20 | def render_str(self, tpl: CommandTemplate, ctx: Context) -> str: 21 | return ''.join(v for _, v in self.interpolate(tpl, ctx)) 22 | 23 | def render_markup(self, tpl: CommandTemplate, ctx: Context) -> TextMarkup: 24 | return self.try_render_markup(tpl, ctx)[0] 25 | 26 | def try_render_markup(self, tpl: CommandTemplate, ctx: Context) -> tuple[TextMarkup, list[InterpolationError]]: 27 | markup: TextMarkup = [] 28 | errors: list[InterpolationError] = [] 29 | for item in self.try_interpolate(tpl, ctx): 30 | match item: 31 | case InterpolationError(): 32 | markup.append(('error', f'')) 33 | errors.append(item) 34 | case (_, ''): 35 | continue 36 | case (Text(), value): 37 | markup.append(('text', value)) 38 | case (Field(), value): 39 | markup.append(('field', value)) 40 | return markup, errors 41 | 42 | def interpolate(self, tpl: CommandTemplate, ctx: Context) -> Iterable[tuple[Node, str]]: 43 | errors: list[InterpolationError] = [] 44 | for item in self.try_interpolate(tpl, ctx): 45 | if isinstance(item, InterpolationError): 46 | errors.append(item) 47 | else: 48 | yield item 49 | if errors: 50 | raise InterpolationErrorGroup('Interpolation errors', errors) 51 | 52 | def try_interpolate(self, tpl: CommandTemplate, ctx: Context) -> Iterable[tuple[Node, str] | InterpolationError]: 53 | for node in tpl.nodes: 54 | if isinstance(node, Text): 55 | yield (node, node.value) 56 | continue 57 | try: 58 | value, _ = self._formatter.get_field(node.name, (), ctx) 59 | except KeyError as err: 60 | yield InvalidContext(f'Missing context key: {err}', node) 61 | continue 62 | except Exception as err: 63 | yield InvalidContext(f'Invalid context: {err}', node) 64 | continue 65 | if node.conversion: 66 | try: 67 | value = self._formatter.convert_field(value, node.conversion) 68 | except ValueError as err: 69 | yield InterpolationError(str(err), node) 70 | continue 71 | if node.format_spec: 72 | try: 73 | value = self._formatter.format_field(value, node.format_spec) 74 | except ValueError as err: 75 | yield InterpolationError(str(err), node) 76 | continue 77 | yield (node, value) 78 | 79 | 80 | class _CommandFormatter(_StringFormatter): 81 | def get_value(self, key: int | str, args: Sequence[Any], kwargs: Mapping[str, Any]) -> Any: 82 | # we override so that we don't have to convert numeric fields 83 | # to integers and split args and kwargs 84 | return kwargs[str(key)] 85 | 86 | def convert_field(self, value: Any, conversion: str) -> Any: 87 | match conversion: 88 | case 'q': 89 | return shlex.quote(str(value)) 90 | case _: 91 | return super().convert_field(value, conversion) 92 | 93 | def format_field(self, value: Any, format_spec: str) -> Any: 94 | try: 95 | return super().format_field(value, format_spec) 96 | except: 97 | # Allow integer-specific format specs (i.e. {:X}) for decimals 98 | if isinstance(value, Decimal) and is_integer_decimal(value): 99 | return super().format_field(int(value), format_spec) 100 | raise 101 | -------------------------------------------------------------------------------- /clisnips/tui/widgets/edit.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import urwid 4 | 5 | from clisnips.tui.urwid_types import TextMarkup 6 | 7 | 8 | def _next_word_position(text: str, start_position: int) -> int: 9 | matches = re.finditer(r'(\b\W+|$)', text, flags=re.UNICODE) 10 | positions = (m.start() for m in matches) 11 | return next( 12 | (p for p in positions if p > start_position), 13 | len(text), 14 | ) 15 | 16 | 17 | def _prev_word_position(text: str, start_position: int) -> int: 18 | matches = re.finditer(r'(\w+\b|^)', text, flags=re.UNICODE) 19 | positions = reversed([m.start() for m in matches]) 20 | return next( 21 | (p for p in positions if p < start_position), 22 | 0, 23 | ) 24 | 25 | 26 | def _next_line_position(text: str, start_position: int) -> int: 27 | pos = text.find('\n', start_position) 28 | return pos if pos > -1 else len(text) - 1 29 | 30 | 31 | def _prev_line_position(text: str, start_position: int) -> int: 32 | pos = text.rfind('\n', 0, start_position) 33 | return pos + 1 if pos > -1 else 0 34 | 35 | 36 | class EmacsEdit(urwid.Edit): 37 | def keypress(self, size, key: str): 38 | match key: 39 | case 'ctrl left': 40 | return super().keypress(size, 'home') 41 | case 'ctrl right': 42 | return super().keypress(size, 'end') 43 | case 'meta right' | 'meta f': 44 | # goto next word 45 | self.edit_pos = _next_word_position(self.edit_text, self.edit_pos) 46 | return None 47 | case 'meta left' | 'meta b': 48 | # goto previous word 49 | self.edit_pos = _prev_word_position(self.edit_text, self.edit_pos) 50 | return None 51 | case 'ctrl k': 52 | # delete to EOL 53 | start = self.edit_pos 54 | end = _next_line_position(self.edit_text, start) 55 | self.edit_text = self.edit_text[:start] + self.edit_text[end:] 56 | return None 57 | case 'ctrl u' | 'ctrl backspace': 58 | # delete to SOL 59 | end = self.edit_pos 60 | start = _prev_line_position(self.edit_text, end) 61 | self.edit_text = self.edit_text[:start] + self.edit_text[end:] 62 | self.edit_pos = start 63 | return None 64 | case 'ctrl d': 65 | # delete char under cursor 66 | return super().keypress(size, 'delete') 67 | case 'meta d' | 'meta backspace': 68 | # delete next word 69 | start = self.edit_pos 70 | end = _next_word_position(self.edit_text, start) 71 | self.edit_text = self.edit_text[:start] + self.edit_text[end:] 72 | return None 73 | case 'ctrl w': 74 | # delete previous word 75 | end = self.edit_pos 76 | start = _prev_word_position(self.edit_text, end) 77 | self.edit_text = self.edit_text[:start] + self.edit_text[end:] 78 | self.edit_pos = start 79 | return None 80 | case 'space': 81 | return super().keypress(size, ' ') 82 | case _: 83 | return super().keypress(size, key) 84 | 85 | 86 | class SourceEdit(EmacsEdit): 87 | """ 88 | Edit subclass that supports markup. 89 | This works by calling set_edit_markup from the change event 90 | as well as whenever markup changes while text does not. 91 | """ 92 | 93 | def __init__(self, *args, **kwargs): 94 | super().__init__(*args, **kwargs) 95 | self._edit_attrs = [] 96 | 97 | def set_edit_markup(self, markup: TextMarkup): 98 | """ 99 | Call this when markup changes but the underlying text does not. 100 | You should arrange for this to be called from the 'change' signal. 101 | """ 102 | if markup: 103 | self._edit_text, self._edit_attrs = urwid.decompose_tagmarkup(markup) 104 | else: 105 | self._edit_text, self._edit_attrs = '', [] 106 | # This is redundant when we're called off the 'change' signal. 107 | # I'm assuming this is cheap, making that ok. 108 | self._invalidate() 109 | 110 | def get_text(self): 111 | return self._caption + self._edit_text, self._attrib + self._edit_attrs 112 | -------------------------------------------------------------------------------- /clisnips/tui/widgets/table/input_processor.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import math 3 | import sys 4 | import time 5 | 6 | 7 | class Direction(enum.StrEnum): 8 | UP = enum.auto() 9 | DOWN = enum.auto() 10 | LEFT = enum.auto() 11 | RIGHT = enum.auto() 12 | 13 | 14 | class Action(enum.StrEnum): 15 | MOVE = enum.auto() 16 | SCROLL = enum.auto() 17 | 18 | 19 | class NavigationCommand: 20 | def __init__(self, action: Action, direction: Direction, offset: int | str): 21 | self.action = action 22 | self.direction = direction 23 | self.offset = offset 24 | 25 | 26 | class InputProcessor: 27 | """ 28 | Implements support for VIM style keys & basic commands. 29 | Supported keys: 30 | j,k - scroll 1 row up/down 31 | h,l - scroll 1 column left/right 32 | ctrl-[u/d] - scroll rows page up/down 33 | 0 - go to first column 34 | $ - go to last column 35 | G - go to last row 36 | Supported commands: 37 | [number]j/k/h/l - scroll [number] rows/columns 38 | gg - go to first row 39 | """ 40 | 41 | def __init__(self): 42 | self._last_key_press = -math.inf 43 | self._pending_command = '' 44 | self._key_directions = { 45 | 'home': Direction.UP, 46 | 'ctrl u': Direction.UP, 47 | 'up': Direction.UP, 48 | 'k': Direction.UP, 49 | 'page up': Direction.UP, 50 | 'end': Direction.DOWN, 51 | 'down': Direction.DOWN, 52 | 'j': Direction.DOWN, 53 | 'ctrl d': Direction.DOWN, 54 | 'G': Direction.DOWN, 55 | 'page down': Direction.DOWN, 56 | 'left': Direction.LEFT, 57 | '0': Direction.LEFT, 58 | 'h': Direction.LEFT, 59 | 'ctrl left': Direction.LEFT, 60 | 'right': Direction.RIGHT, 61 | '$': Direction.RIGHT, 62 | 'l': Direction.RIGHT, 63 | 'ctrl right': Direction.RIGHT, 64 | } 65 | self._single_offset_keys = {'up', 'down', 'left', 'right', 'j', 'k', 'h', 'l'} 66 | self._max_offset_keys = {'home', 'end', 'ctrl left', 'ctrl right', '0', '$', 'G'} 67 | self._page_offset_keys = {'page up', 'page down', 'ctrl u', 'ctrl d'} 68 | self._command_keys = {'g'} | {str(i) for i in range(1, 10)} 69 | 70 | def process_key(self, key: str) -> NavigationCommand | None: 71 | if not self._pending_command: 72 | if key in self._single_offset_keys: 73 | return NavigationCommand(Action.MOVE, self._key_directions[key], 1) 74 | if key in self._max_offset_keys: 75 | return NavigationCommand(Action.MOVE, self._key_directions[key], sys.maxsize) 76 | if key in self._page_offset_keys: 77 | return NavigationCommand(Action.MOVE, self._key_directions[key], 'page') 78 | if key in self._command_keys: 79 | self._last_key_press = time.time() 80 | self._pending_command = key 81 | return None 82 | return None 83 | 84 | diff = time.time() - self._last_key_press 85 | if diff > self._timeout(key): 86 | self._clear_pending_command() 87 | return None 88 | 89 | return self._process_command(key) 90 | 91 | def _timeout(self, key: str) -> float: 92 | if self._pending_command.isdigit(): 93 | return 1.0 94 | return 0.25 95 | 96 | def _clear_pending_command(self): 97 | self._pending_command = '' 98 | self._last_key_press = -math.inf 99 | 100 | def _process_command(self, key: str) -> NavigationCommand | None: 101 | if key == 'esc': 102 | self._clear_pending_command() 103 | return 104 | 105 | if self._pending_command.isdigit() and key.isdigit(): 106 | self._pending_command += key 107 | return 108 | 109 | if self._pending_command.isdigit() and not key.isdigit(): 110 | offset = int(self._pending_command) 111 | self._clear_pending_command() 112 | 113 | if key not in self._key_directions: 114 | return 115 | return NavigationCommand(Action.SCROLL, self._key_directions[key], offset) 116 | 117 | if self._pending_command == 'g' and key == 'g': 118 | self._clear_pending_command() 119 | return NavigationCommand(Action.SCROLL, Direction.UP, sys.maxsize) 120 | -------------------------------------------------------------------------------- /clisnips/importers/clicompanion.py: -------------------------------------------------------------------------------- 1 | """ 2 | Importer for CliCompanion2 local command lists. 3 | 4 | The file format is a TSV list, where each line has 3 fields: 5 | * the command, possibly including `?` argument placeholders. 6 | * the «ui», a comma (or space) separated list of strings, 7 | which are the readable names of each `?` in the command 8 | * a textual description of the command 9 | 10 | Example: 11 | ``` 12 | mv ? ?src, destMoves "src" to "dest" 13 | ``` 14 | 15 | The parsing code was adapted from: 16 | https://bazaar.launchpad.net/~clicompanion-devs/clicompanion/trunk/view/head:/plugins/LocalCommandList.py 17 | """ 18 | 19 | import logging 20 | import re 21 | import time 22 | from collections.abc import Iterable 23 | from pathlib import Path 24 | from typing import TextIO 25 | 26 | from clisnips.database import ImportableSnippet 27 | from clisnips.utils.list import pad_list 28 | 29 | from .base import Importer, SnippetAdapter 30 | 31 | logger = logging.getLogger(__name__) 32 | 33 | # Looks for a question-mark that is not escaped 34 | # (not preceded by an odd number of backslashes) 35 | _ARGS_RE = re.compile(r'(? None: 40 | start_time = time.time() 41 | logger.info(f'Importing snippets from {path}') 42 | 43 | with open(path) as fp: 44 | if self._dry_run: 45 | for _ in _get_snippets(fp): 46 | ... 47 | else: 48 | self._db.insert_many(_get_snippets(fp)) 49 | logger.info('Rebuilding & optimizing search index') 50 | if not self._dry_run: 51 | self._db.rebuild_index() 52 | self._db.optimize_index() 53 | 54 | elapsed_time = time.time() - start_time 55 | logger.info(f'Imported in {elapsed_time:.1f} seconds.', extra={'color': 'success'}) 56 | 57 | 58 | def _get_snippets(file: TextIO) -> Iterable[ImportableSnippet]: 59 | for cmd, ui, desc in _parse(file): 60 | yield _translate(cmd, ui, desc) 61 | 62 | 63 | def _parse(file: TextIO) -> list[tuple[str, str, str]]: 64 | commands, seen = [], set() 65 | # try to detect if the line is a old fashion config line 66 | # (separated by ':') 67 | no_tabs = True 68 | some_colon = False 69 | for line in file: 70 | line = line.strip() 71 | if not line: 72 | continue 73 | fields = [f.strip() for f in line.split('\t', 2)] 74 | cmd, ui, desc = pad_list(fields, '', 3) 75 | if ':' in cmd: 76 | some_colon = True 77 | if ui or desc: 78 | no_tabs = False 79 | row = (cmd, ui, desc) 80 | if cmd and row not in seen: 81 | seen.add(row) 82 | commands.append(row) 83 | if no_tabs and some_colon: 84 | # None of the commands had tabs, 85 | # and at least one had ':' in the cmd... 86 | # This is most probably an old config style. 87 | for i, (cmd, ui, desc) in enumerate(commands): 88 | fields = [f.strip() for f in cmd.split('\t', 2)] 89 | cmd, ui, desc = pad_list(fields, '', 3) 90 | commands[i] = (cmd, ui, desc) 91 | return commands 92 | 93 | 94 | def _translate(cmd: str, ui: str, desc: str) -> ImportableSnippet: 95 | """ 96 | Since ui is free form text, we have to make an educated guess... 97 | """ 98 | result = SnippetAdapter.validate_python( 99 | { 100 | 'title': desc, 101 | 'cmd': cmd, 102 | 'doc': ui, 103 | 'tag': cmd.split(None, 1)[0], 104 | } 105 | ) 106 | nargs = len(_ARGS_RE.findall(cmd)) 107 | if not nargs: 108 | # no user arguments 109 | return result 110 | # replace ?s by numbered params 111 | for i in range(nargs): 112 | cmd = _ARGS_RE.sub('{%s}' % i, cmd, count=1) 113 | result['cmd'] = cmd 114 | # try to find a comma separated list 115 | by_comma = [i.strip() for i in ui.split(',')] 116 | if len(by_comma) == nargs: 117 | doc = [] 118 | for i, arg in enumerate(by_comma): 119 | doc.append('{%s} (string) %s' % (i, arg)) # noqa: UP031 120 | result['doc'] = '\n'.join(doc) 121 | return result 122 | # try to find a space separated list 123 | by_space = [i.strip() for i in ui.split()] 124 | if len(by_space) == nargs: 125 | doc = [] 126 | for i, arg in enumerate(by_space): 127 | doc.append('{%s} (string) %s' % (i, arg)) # noqa: UP031 128 | result['doc'] = '\n'.join(doc) 129 | return result 130 | # else let ui be free form doc 131 | doc = [ui + '\n'] 132 | for i in range(nargs): 133 | doc.append('{%s} (string)' % i) 134 | result['doc'] = '\n'.join(doc) 135 | return result 136 | --------------------------------------------------------------------------------