QCodeSitter 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- QCodeSitter/__init__.py +38 -0
- QCodeSitter/behaviors/__init__.py +115 -0
- QCodeSitter/behaviors/auto_bracket.py +141 -0
- QCodeSitter/behaviors/code_folding.py +530 -0
- QCodeSitter/behaviors/comment_toggle.py +71 -0
- QCodeSitter/behaviors/highlight_matching_brackets.py +181 -0
- QCodeSitter/behaviors/highlight_matching_selection.py +59 -0
- QCodeSitter/behaviors/line_numbers.py +49 -0
- QCodeSitter/behaviors/multi_cursor_paint.py +131 -0
- QCodeSitter/behaviors/overscroll.py +104 -0
- QCodeSitter/behaviors/smart_indent.py +191 -0
- QCodeSitter/behaviors/syntax_highlighting.py +181 -0
- QCodeSitter/behaviors/tab_completion/__init__.py +257 -0
- QCodeSitter/behaviors/tab_completion/popup.py +123 -0
- QCodeSitter/behaviors/tab_completion/providers.py +66 -0
- QCodeSitter/behaviors/tab_completion/types.py +55 -0
- QCodeSitter/code_editor.py +540 -0
- QCodeSitter/cursors/__init__.py +8 -0
- QCodeSitter/cursors/clipboard.py +51 -0
- QCodeSitter/cursors/iterator.py +141 -0
- QCodeSitter/cursors/line_edits.py +128 -0
- QCodeSitter/cursors/multi.py +240 -0
- QCodeSitter/cursors/state.py +55 -0
- QCodeSitter/cursors/tracked.py +97 -0
- QCodeSitter/defaults.py +93 -0
- QCodeSitter/editing_keys.py +150 -0
- QCodeSitter/editor_options.py +246 -0
- QCodeSitter/gui_colors.py +51 -0
- QCodeSitter/gutters.py +141 -0
- QCodeSitter/integrations/__init__.py +1 -0
- QCodeSitter/integrations/preditor_interface.py +390 -0
- QCodeSitter/languages/__init__.py +99 -0
- QCodeSitter/languages/base.py +60 -0
- QCodeSitter/languages/python.py +146 -0
- QCodeSitter/languages/python_highlights.py +444 -0
- QCodeSitter/py.typed +0 -0
- QCodeSitter/queries/__init__.py +73 -0
- QCodeSitter/queries/predicates.py +172 -0
- QCodeSitter/syntax_specs.py +65 -0
- QCodeSitter/themes.py +271 -0
- QCodeSitter/tracked_document.py +107 -0
- QCodeSitter/tree_manager.py +180 -0
- QCodeSitter/utils.py +84 -0
- qcodesitter-0.1.0.dist-info/METADATA +255 -0
- qcodesitter-0.1.0.dist-info/RECORD +49 -0
- qcodesitter-0.1.0.dist-info/WHEEL +5 -0
- qcodesitter-0.1.0.dist-info/entry_points.txt +2 -0
- qcodesitter-0.1.0.dist-info/licenses/LICENSE.txt +7 -0
- qcodesitter-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from Qt import QtCore, QtGui, QtWidgets
|
|
4
|
+
|
|
5
|
+
from ..cursors.line_edits import replace_lines_at_cursors
|
|
6
|
+
from ..languages import IndentRules, language_spec
|
|
7
|
+
from ..tracked_document import utf16_to_index
|
|
8
|
+
from ..utils import KEY, MM, MO, add_menu_action, dedent_string, leading_whitespace
|
|
9
|
+
from . import Behavior
|
|
10
|
+
|
|
11
|
+
CLOSING_BRACKETS = ("]", ")", "}")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SmartIndent(Behavior):
|
|
15
|
+
"""Syntax-aware indentation for Enter, Tab, Shift+Tab, Backspace and closing brackets
|
|
16
|
+
|
|
17
|
+
The indent settings come from the options, and the indent rules come from
|
|
18
|
+
the editor language's LanguageSpec
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def _use_tabs(self) -> bool:
|
|
23
|
+
return self.options["indent_using_tabs"]
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def _width(self) -> int:
|
|
27
|
+
return self.options["space_indent_width"]
|
|
28
|
+
|
|
29
|
+
def add_menu_actions(
|
|
30
|
+
self, menu: QtWidgets.QMenu, pos: QtCore.QPoint | None
|
|
31
|
+
) -> None:
|
|
32
|
+
add_menu_action(menu, "Indent", self.insert_indent, "Tab")
|
|
33
|
+
add_menu_action(menu, "Unindent", self.unindent, "Shift+Tab")
|
|
34
|
+
|
|
35
|
+
def indent_rules(self) -> IndentRules:
|
|
36
|
+
spec = language_spec(self.editor.tree_manager.language)
|
|
37
|
+
return spec.indent_rules(self.editor.tree_manager)
|
|
38
|
+
|
|
39
|
+
def handle_key_press(self, event: QtGui.QKeyEvent) -> bool:
|
|
40
|
+
key = event.key()
|
|
41
|
+
if key == KEY.Key_Tab:
|
|
42
|
+
self.insert_indent()
|
|
43
|
+
return True
|
|
44
|
+
if key == KEY.Key_Backtab:
|
|
45
|
+
self.unindent()
|
|
46
|
+
return True
|
|
47
|
+
# These only handle some cursors, and leave the rest to the editor
|
|
48
|
+
if key in (KEY.Key_Return, KEY.Key_Enter):
|
|
49
|
+
self.smart_newline()
|
|
50
|
+
elif key == KEY.Key_Backspace:
|
|
51
|
+
self._smart_backspace()
|
|
52
|
+
elif event.text() in CLOSING_BRACKETS:
|
|
53
|
+
self._smart_closing_bracket(event.text())
|
|
54
|
+
else:
|
|
55
|
+
return False
|
|
56
|
+
return self.editor.citer.all_completed()
|
|
57
|
+
|
|
58
|
+
def _one_indent(self) -> str:
|
|
59
|
+
return "\t" if self._use_tabs else " " * self._width
|
|
60
|
+
|
|
61
|
+
def _dedent(self, indent: str) -> str:
|
|
62
|
+
return dedent_string(indent, self._use_tabs, self._width)
|
|
63
|
+
|
|
64
|
+
def _newline_indent(
|
|
65
|
+
self, cursor: QtGui.QTextCursor, rules: IndentRules
|
|
66
|
+
) -> tuple[str, int]:
|
|
67
|
+
"""Work out the indentation to insert after a newline at the cursor
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
The text to insert after the newline
|
|
71
|
+
How far to move the cursor back from the end of that text
|
|
72
|
+
"""
|
|
73
|
+
block = cursor.block()
|
|
74
|
+
line_text = block.text()
|
|
75
|
+
indent = leading_whitespace(line_text)
|
|
76
|
+
col = cursor.positionInBlock()
|
|
77
|
+
|
|
78
|
+
# At the start of a line, move the line down unchanged
|
|
79
|
+
if col == 0:
|
|
80
|
+
return "", 0
|
|
81
|
+
|
|
82
|
+
# A blank line keeps its indentation
|
|
83
|
+
if not line_text.strip():
|
|
84
|
+
return indent, 0
|
|
85
|
+
|
|
86
|
+
# Between a pair of brackets, put the closing bracket on its own line
|
|
87
|
+
# below an indented empty line
|
|
88
|
+
index = utf16_to_index(line_text, col)
|
|
89
|
+
if line_text[index - 1 : index + 1] in self.options["indent_bracket_pairs"]:
|
|
90
|
+
return indent + self._one_indent() + "\n" + indent, len(indent) + 1
|
|
91
|
+
|
|
92
|
+
# Look at the character just before the cursor to find the statement we just finished
|
|
93
|
+
row = block.blockNumber()
|
|
94
|
+
if rules.should_indent_after(row, col - 1):
|
|
95
|
+
return indent + self._one_indent(), 0
|
|
96
|
+
if rules.should_dedent_after(row, col - 1):
|
|
97
|
+
return self._dedent(indent), 0
|
|
98
|
+
return indent, 0
|
|
99
|
+
|
|
100
|
+
def smart_newline(self) -> None:
|
|
101
|
+
"""Insert a newline indented to suit the code around each cursor"""
|
|
102
|
+
# Work out every indent before editing, because the edits change the tree
|
|
103
|
+
citer = self.editor.citer
|
|
104
|
+
rules = self.indent_rules()
|
|
105
|
+
indents = []
|
|
106
|
+
with self.editor.edit_session():
|
|
107
|
+
for cursor in citer.read_cursors():
|
|
108
|
+
if cursor.hasSelection():
|
|
109
|
+
cursor.setPosition(cursor.selectionStart())
|
|
110
|
+
indents.append(self._newline_indent(cursor, rules))
|
|
111
|
+
|
|
112
|
+
for i, cursor in enumerate(citer.iterate_cursors(complete_all=True)):
|
|
113
|
+
indent_str, move_back = indents[i]
|
|
114
|
+
cursor.insertText("\n" + indent_str)
|
|
115
|
+
if move_back:
|
|
116
|
+
cursor.movePosition(MO.Left, MM.MoveAnchor, move_back)
|
|
117
|
+
|
|
118
|
+
def insert_indent(self) -> None:
|
|
119
|
+
"""Indent to the next tab stop, or indent every cursor's lines if any selects
|
|
120
|
+
|
|
121
|
+
A selection anywhere indents whole lines, so a cursor on a line that
|
|
122
|
+
another cursor's selection indents doesn't indent it twice
|
|
123
|
+
"""
|
|
124
|
+
citer = self.editor.citer
|
|
125
|
+
width = self._width
|
|
126
|
+
indent = self._one_indent()
|
|
127
|
+
|
|
128
|
+
def indent_lines(lines: list[str]) -> list[str]:
|
|
129
|
+
return [indent + ln if ln.strip() else ln for ln in lines]
|
|
130
|
+
|
|
131
|
+
with self.editor.edit_session():
|
|
132
|
+
if any(cursor.hasSelection() for cursor in citer.read_cursors()):
|
|
133
|
+
replace_lines_at_cursors(citer, indent_lines)
|
|
134
|
+
return
|
|
135
|
+
for cursor in citer.iterate_cursors(complete_all=True):
|
|
136
|
+
if self._use_tabs:
|
|
137
|
+
cursor.insertText("\t")
|
|
138
|
+
else:
|
|
139
|
+
cursor.insertText(" " * (width - cursor.positionInBlock() % width))
|
|
140
|
+
|
|
141
|
+
def unindent(self) -> None:
|
|
142
|
+
"""Remove one indent from every line of each cursor's selection or line"""
|
|
143
|
+
citer = self.editor.citer
|
|
144
|
+
width = self._width
|
|
145
|
+
use_tabs = self._use_tabs
|
|
146
|
+
|
|
147
|
+
def unindent_line(line: str) -> str:
|
|
148
|
+
if use_tabs:
|
|
149
|
+
return line.removeprefix("\t")
|
|
150
|
+
return line[:width].lstrip(" ") + line[width:]
|
|
151
|
+
|
|
152
|
+
with self.editor.edit_session():
|
|
153
|
+
replace_lines_at_cursors(
|
|
154
|
+
citer, lambda lines: [unindent_line(ln) for ln in lines]
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def _smart_backspace(self) -> None:
|
|
158
|
+
"""Backspace over a whole indent when the cursor is in the indentation"""
|
|
159
|
+
if self._use_tabs:
|
|
160
|
+
return
|
|
161
|
+
citer = self.editor.citer
|
|
162
|
+
width = self._width
|
|
163
|
+
for cursor in citer.iterate_cursors():
|
|
164
|
+
col = cursor.positionInBlock()
|
|
165
|
+
if cursor.hasSelection() or col == 0:
|
|
166
|
+
continue
|
|
167
|
+
# Spaces are one code unit each, so col is also the str index
|
|
168
|
+
if set(cursor.block().text()[:col]) != {" "}:
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
# Delete back to the previous tab stop
|
|
172
|
+
cursor.movePosition(MO.Left, MM.KeepAnchor, col % width or width)
|
|
173
|
+
cursor.removeSelectedText()
|
|
174
|
+
citer.mark_cursor_completed()
|
|
175
|
+
|
|
176
|
+
def _smart_closing_bracket(self, bracket: str) -> None:
|
|
177
|
+
"""Dedent a blank line when a closing bracket is typed on it"""
|
|
178
|
+
citer = self.editor.citer
|
|
179
|
+
for cursor in citer.iterate_cursors():
|
|
180
|
+
if cursor.hasSelection():
|
|
181
|
+
cursor.removeSelectedText()
|
|
182
|
+
|
|
183
|
+
line_text = cursor.block().text()
|
|
184
|
+
indent = leading_whitespace(line_text)
|
|
185
|
+
if not indent or line_text.strip():
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
cursor.movePosition(MO.StartOfLine)
|
|
189
|
+
cursor.movePosition(MO.EndOfLine, MM.KeepAnchor)
|
|
190
|
+
cursor.insertText(self._dedent(indent) + bracket)
|
|
191
|
+
citer.mark_cursor_completed()
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from Qt import QtGui
|
|
6
|
+
|
|
7
|
+
from ..languages import highlight_query
|
|
8
|
+
from ..queries import query_byte_range
|
|
9
|
+
from ..syntax_specs import SyntaxSpecs, resolve_capture
|
|
10
|
+
from ..tracked_document import len16
|
|
11
|
+
from . import Behavior
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from tree_sitter import Query
|
|
15
|
+
|
|
16
|
+
from ..code_editor import CodeEditor
|
|
17
|
+
from ..tree_manager import TreeManager
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TreeSitterHighlighter(QtGui.QSyntaxHighlighter):
|
|
21
|
+
"""Highlights each block with a tree-sitter query over the block's bytes
|
|
22
|
+
|
|
23
|
+
The SyntaxHighlighting behavior only rehighlights the rows that changed
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
tree_manager: TreeManager,
|
|
29
|
+
query: Query,
|
|
30
|
+
format_specs: SyntaxSpecs,
|
|
31
|
+
) -> None:
|
|
32
|
+
super().__init__(tree_manager.document)
|
|
33
|
+
self.tree_manager = tree_manager
|
|
34
|
+
self._doc = tree_manager.document
|
|
35
|
+
self.query = query
|
|
36
|
+
self.formats = self._compile_formats(format_specs)
|
|
37
|
+
|
|
38
|
+
def _compile_formats(
|
|
39
|
+
self, format_specs: SyntaxSpecs
|
|
40
|
+
) -> dict[str, QtGui.QTextCharFormat]:
|
|
41
|
+
"""Convert the syntax specs into text formats, keyed by capture name"""
|
|
42
|
+
out: dict[str, QtGui.QTextCharFormat] = {}
|
|
43
|
+
|
|
44
|
+
for name, spec in format_specs.items():
|
|
45
|
+
fmt = QtGui.QTextCharFormat()
|
|
46
|
+
if "color" in spec:
|
|
47
|
+
fmt.setForeground(QtGui.QColor(spec["color"]))
|
|
48
|
+
if spec.get("bold"):
|
|
49
|
+
fmt.setFontWeight(QtGui.QFont.Weight.Bold)
|
|
50
|
+
if spec.get("italic"):
|
|
51
|
+
fmt.setFontItalic(True)
|
|
52
|
+
out[name] = fmt
|
|
53
|
+
|
|
54
|
+
# Captures in the query without an explicit format fall back to their
|
|
55
|
+
# parent group. ie: "keyword.return" -> "keyword"
|
|
56
|
+
for name in self.query_capture_names():
|
|
57
|
+
if name not in out:
|
|
58
|
+
fallback = resolve_capture(name, out)
|
|
59
|
+
if fallback is not None:
|
|
60
|
+
out[name] = fallback
|
|
61
|
+
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
def query_capture_names(self) -> list[str]:
|
|
65
|
+
# capture_count is a property, but tree-sitter's stubs call it a method
|
|
66
|
+
count: int = self.query.capture_count # type: ignore[assignment]
|
|
67
|
+
return [self.query.capture_name(i) for i in range(count)]
|
|
68
|
+
|
|
69
|
+
def _block_byte_range(
|
|
70
|
+
self, block: QtGui.QTextBlock, text_len: int
|
|
71
|
+
) -> tuple[int, int]:
|
|
72
|
+
"""Get the UTF-16LE byte range of a block, including its newline"""
|
|
73
|
+
start = self._doc.char_to_byte(block.position())
|
|
74
|
+
next_block = block.next()
|
|
75
|
+
if next_block.isValid():
|
|
76
|
+
return start, self._doc.char_to_byte(next_block.position())
|
|
77
|
+
return start, start + self._doc.char_to_byte(text_len)
|
|
78
|
+
|
|
79
|
+
def highlightBlock(self, text: str) -> None:
|
|
80
|
+
tree = self.tree_manager.tree
|
|
81
|
+
block = self.currentBlock()
|
|
82
|
+
if tree is None or not block.isValid():
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
# Qt positions are UTF-16 code units, but python's len counts code points
|
|
86
|
+
# so they differ when the text contains characters outside the BMP
|
|
87
|
+
text_len = len16(text)
|
|
88
|
+
start_byte, end_byte = self._block_byte_range(block, text_len)
|
|
89
|
+
# Skip empty blocks, and blocks past the end of a tree that is out of date
|
|
90
|
+
root = tree.root_node
|
|
91
|
+
if start_byte >= end_byte or end_byte > root.end_byte:
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
captures = query_byte_range(
|
|
95
|
+
self.query, root, start_byte, end_byte, self._doc.node_text
|
|
96
|
+
)
|
|
97
|
+
block_start = block.position()
|
|
98
|
+
for capture_name, nodes in captures.items():
|
|
99
|
+
fmt = self.formats.get(capture_name)
|
|
100
|
+
if fmt is None:
|
|
101
|
+
continue
|
|
102
|
+
for node in nodes:
|
|
103
|
+
# Block-local UTF-16 positions, clamped to the block
|
|
104
|
+
local_start = self._doc.byte_to_char(node.start_byte) - block_start
|
|
105
|
+
local_end = self._doc.byte_to_char(node.end_byte) - block_start
|
|
106
|
+
local_start, local_end = max(0, local_start), min(text_len, local_end)
|
|
107
|
+
if local_end > local_start:
|
|
108
|
+
self.setFormat(local_start, local_end - local_start, fmt)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class SyntaxHighlighting(Behavior):
|
|
112
|
+
"""Tree-sitter syntax highlighting
|
|
113
|
+
|
|
114
|
+
The query comes from the editor language's LanguageSpec, and the formats
|
|
115
|
+
of its captures from the "syntax" option
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(self, editor: CodeEditor) -> None:
|
|
119
|
+
super().__init__(editor)
|
|
120
|
+
self._syntax: SyntaxSpecs = {}
|
|
121
|
+
self.highlighter: TreeSitterHighlighter | None = None
|
|
122
|
+
self.connect(
|
|
123
|
+
self.editor.tree_manager.languageChanged, self._rebuild_highlighter
|
|
124
|
+
)
|
|
125
|
+
self.connect(self.editor.tree_manager.reparsed, self._on_reparsed)
|
|
126
|
+
self.watch_options({"syntax": self._set_syntax})
|
|
127
|
+
|
|
128
|
+
def _set_syntax(self, syntax: SyntaxSpecs) -> None:
|
|
129
|
+
self._syntax = syntax
|
|
130
|
+
self._rebuild_highlighter()
|
|
131
|
+
|
|
132
|
+
def _rebuild_highlighter(self) -> None:
|
|
133
|
+
"""Highlight with the current language's query and the syntax formats"""
|
|
134
|
+
self._detach_highlighter()
|
|
135
|
+
language = self.editor.tree_manager.language
|
|
136
|
+
query = highlight_query(language) if language is not None else None
|
|
137
|
+
if query is None or not self._syntax:
|
|
138
|
+
return
|
|
139
|
+
self.highlighter = TreeSitterHighlighter(
|
|
140
|
+
self.editor.tree_manager, query, self._syntax
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def _detach_highlighter(self) -> None:
|
|
144
|
+
"""Stop the current highlighter from formatting the document
|
|
145
|
+
|
|
146
|
+
The QSyntaxHighlighter is parented to the document, so dropping our
|
|
147
|
+
reference isn't enough to stop it from running.
|
|
148
|
+
"""
|
|
149
|
+
if self.highlighter is None:
|
|
150
|
+
return
|
|
151
|
+
self.highlighter.setDocument(None)
|
|
152
|
+
self.highlighter.deleteLater()
|
|
153
|
+
self.highlighter = None
|
|
154
|
+
|
|
155
|
+
def _on_reparsed(self) -> None:
|
|
156
|
+
if self.highlighter is None:
|
|
157
|
+
return
|
|
158
|
+
changed_rows = self.editor.tree_manager.changed_rows
|
|
159
|
+
if changed_rows is None:
|
|
160
|
+
self.highlighter.rehighlight()
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
# Only rehighlight the rows that changed instead of the whole document
|
|
164
|
+
# Qt already highlighted the edited blocks with the old tree when the text
|
|
165
|
+
# changed. That goes one block past the edit when text is removed, so
|
|
166
|
+
# include that block too
|
|
167
|
+
doc = self.editor.document()
|
|
168
|
+
done: set[int] = set()
|
|
169
|
+
for first, last in changed_rows:
|
|
170
|
+
block = doc.findBlockByNumber(first)
|
|
171
|
+
for row in range(first, last + 2):
|
|
172
|
+
if not block.isValid():
|
|
173
|
+
break
|
|
174
|
+
if row not in done:
|
|
175
|
+
done.add(row)
|
|
176
|
+
self.highlighter.rehighlightBlock(block)
|
|
177
|
+
block = block.next()
|
|
178
|
+
|
|
179
|
+
def on_remove(self) -> None:
|
|
180
|
+
# Detaching the highlighter from the document also clears its formats
|
|
181
|
+
self._detach_highlighter()
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Iterable
|
|
4
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
5
|
+
|
|
6
|
+
from Qt import QtCore, QtGui
|
|
7
|
+
|
|
8
|
+
from ...languages import language_spec
|
|
9
|
+
from ...tracked_document import len16, utf16_to_index
|
|
10
|
+
from ...utils import KEY, MM, MOD
|
|
11
|
+
from .. import Behavior
|
|
12
|
+
from .popup import CompletionPopup
|
|
13
|
+
from .types import Completion, CompletionContext, Provider
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from ...code_editor import CodeEditor
|
|
17
|
+
|
|
18
|
+
T_Provider = TypeVar("T_Provider", bound=Provider)
|
|
19
|
+
|
|
20
|
+
MIN_PREFIX_LENGTH = 2
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def is_identifier_char(char: str) -> bool:
|
|
24
|
+
"""Whether a character can be part of an identifier, like a letter or digit"""
|
|
25
|
+
return char.isalnum() or char == "_"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def identifier_before(line: str, index: int) -> tuple[int, str]:
|
|
29
|
+
"""Get the identifier that ends at a str index in the line
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
The str index where the identifier starts
|
|
33
|
+
The identifier, like "jo" in "os.path.jo|"
|
|
34
|
+
"""
|
|
35
|
+
start = index
|
|
36
|
+
while start > 0 and is_identifier_char(line[start - 1]):
|
|
37
|
+
start -= 1
|
|
38
|
+
return start, line[start:index]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def context_at_cursor(editor: CodeEditor) -> CompletionContext:
|
|
42
|
+
"""Get the completion context at the editor's primary cursor"""
|
|
43
|
+
cursor = editor.textCursor()
|
|
44
|
+
block = cursor.block()
|
|
45
|
+
line = block.text()
|
|
46
|
+
index = utf16_to_index(line, cursor.positionInBlock())
|
|
47
|
+
start, prefix = identifier_before(line, index)
|
|
48
|
+
return CompletionContext(
|
|
49
|
+
line_num=block.blockNumber(),
|
|
50
|
+
index=index,
|
|
51
|
+
prefix=prefix,
|
|
52
|
+
start=start,
|
|
53
|
+
line=line,
|
|
54
|
+
node=editor.tree_manager.node_at_char(cursor.position()),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def merge_completions(completions: Iterable[Completion]) -> set[Completion]:
|
|
59
|
+
"""Merge the completions of the same name and kind, keeping the highest priority"""
|
|
60
|
+
best: dict[Completion, Completion] = {}
|
|
61
|
+
for comp in completions:
|
|
62
|
+
current = best.get(comp)
|
|
63
|
+
if current is None or comp.priority > current.priority:
|
|
64
|
+
best[comp] = comp
|
|
65
|
+
return set(best.values())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TabCompletion(Behavior):
|
|
69
|
+
"""Shows a completion popup while typing an identifier
|
|
70
|
+
|
|
71
|
+
Completions come from the providers added with `add_provider`. Tab or
|
|
72
|
+
Enter accepts the selected completion, and Up/Down change it.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
# Tab and Enter accept a completion before SmartIndent can indent with them
|
|
76
|
+
KEY_PRIORITY = 10
|
|
77
|
+
|
|
78
|
+
def __init__(self, editor: CodeEditor) -> None:
|
|
79
|
+
super().__init__(editor)
|
|
80
|
+
self._providers: list[Provider] = []
|
|
81
|
+
# Where the shown completions were asked for, or None before the first time
|
|
82
|
+
self._last_context: CompletionContext | None = None
|
|
83
|
+
# Whether the text changed since the cursor last moved, so moving
|
|
84
|
+
# the cursor around without typing doesn't open the popup
|
|
85
|
+
self._typing_mode = False
|
|
86
|
+
|
|
87
|
+
self.connect(self.editor.cursorPositionChanged, self.on_cursor_changed)
|
|
88
|
+
self.connect(self.editor.textChanged, self.on_text_changed)
|
|
89
|
+
|
|
90
|
+
# Wait for a pause in typing before asking the providers
|
|
91
|
+
self.debounce_timer = QtCore.QTimer(self.editor)
|
|
92
|
+
self.debounce_timer.setSingleShot(True)
|
|
93
|
+
self.debounce_timer.timeout.connect(self.refresh_completions)
|
|
94
|
+
|
|
95
|
+
self.completion_popup = CompletionPopup(self.editor)
|
|
96
|
+
self.watch_options({"colors": self.completion_popup.set_colors})
|
|
97
|
+
|
|
98
|
+
def add_provider(self, provider_cls: type[T_Provider]) -> T_Provider:
|
|
99
|
+
provider = provider_cls(self.editor)
|
|
100
|
+
self._providers.append(provider)
|
|
101
|
+
return provider
|
|
102
|
+
|
|
103
|
+
def on_remove(self) -> None:
|
|
104
|
+
self.debounce_timer.stop()
|
|
105
|
+
self.debounce_timer.deleteLater()
|
|
106
|
+
self.completion_popup.hide()
|
|
107
|
+
self.completion_popup.deleteLater()
|
|
108
|
+
|
|
109
|
+
def on_text_changed(self) -> None:
|
|
110
|
+
self._typing_mode = True
|
|
111
|
+
|
|
112
|
+
def _showing_completions_for(self, context: CompletionContext) -> bool:
|
|
113
|
+
"""Whether the popup is showing the completions for the identifier at context
|
|
114
|
+
|
|
115
|
+
Once the popup hides, the next identifier asks the providers again
|
|
116
|
+
"""
|
|
117
|
+
return (
|
|
118
|
+
self.completion_popup.isVisible()
|
|
119
|
+
and self._last_context is not None
|
|
120
|
+
and context.line_num == self._last_context.line_num
|
|
121
|
+
and context.start == self._last_context.start
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def _hide_or_filter(self) -> CompletionContext | None:
|
|
125
|
+
"""Hide the popup, or filter what it shows, for the identifier at the cursor
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
The cursor's context when the providers need asking for new
|
|
129
|
+
completions, or None when the popup was hidden or filtered
|
|
130
|
+
"""
|
|
131
|
+
context = context_at_cursor(self.editor)
|
|
132
|
+
if not self._should_trigger(context):
|
|
133
|
+
self.completion_popup.hide()
|
|
134
|
+
return None
|
|
135
|
+
if self._showing_completions_for(context):
|
|
136
|
+
# Still completing the same word, so just filter what's showing
|
|
137
|
+
self.completion_popup.update_filter(context.prefix)
|
|
138
|
+
self._last_context = context
|
|
139
|
+
return None
|
|
140
|
+
return context
|
|
141
|
+
|
|
142
|
+
def on_cursor_changed(self) -> None:
|
|
143
|
+
if self._hide_or_filter() is not None and self._typing_mode:
|
|
144
|
+
self.debounce_timer.start(self.options["debounce_delay"])
|
|
145
|
+
self._typing_mode = False
|
|
146
|
+
|
|
147
|
+
def _should_trigger(self, context: CompletionContext) -> bool:
|
|
148
|
+
"""Whether the cursor is at the end of a long enough identifier, in code"""
|
|
149
|
+
spec = language_spec(self.editor.tree_manager.language)
|
|
150
|
+
if context.node is not None and context.node.type in spec.no_completion_types:
|
|
151
|
+
return False
|
|
152
|
+
if is_identifier_char(context.line[context.index : context.index + 1]):
|
|
153
|
+
# In the middle of an identifier
|
|
154
|
+
return False
|
|
155
|
+
prefix = context.prefix
|
|
156
|
+
return len(prefix) >= MIN_PREFIX_LENGTH and (
|
|
157
|
+
prefix[0].isalpha() or prefix[0] == "_"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def refresh_completions(self) -> None:
|
|
161
|
+
"""Show the completions for the identifier at the cursor
|
|
162
|
+
|
|
163
|
+
This asks the providers for the completions of a new identifier, and
|
|
164
|
+
only filters the shown ones while still typing the same identifier.
|
|
165
|
+
It runs once typing pauses for the "debounce_delay" option
|
|
166
|
+
"""
|
|
167
|
+
# The context might have changed while waiting
|
|
168
|
+
context = self._hide_or_filter()
|
|
169
|
+
if context is None:
|
|
170
|
+
return
|
|
171
|
+
completions = merge_completions(
|
|
172
|
+
comp for provider in self._providers for comp in provider.provide(context)
|
|
173
|
+
)
|
|
174
|
+
self.completion_popup.show_completions(completions, context.prefix)
|
|
175
|
+
self._last_context = context
|
|
176
|
+
|
|
177
|
+
# ------------------------------------------------------------------
|
|
178
|
+
# Keys. Each returns whether to consume the key
|
|
179
|
+
# ------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
def accept_item(self) -> bool:
|
|
182
|
+
"""Replace the prefix before every cursor with the selected completion
|
|
183
|
+
|
|
184
|
+
A cursor with other text before it only gets the rest of the completion.
|
|
185
|
+
Call it while the popup shows
|
|
186
|
+
"""
|
|
187
|
+
comp = self.completion_popup.selected_completion()
|
|
188
|
+
if comp is not None:
|
|
189
|
+
prefix = self.completion_popup.current_prefix
|
|
190
|
+
citer = self.editor.citer
|
|
191
|
+
with self.editor.edit_session():
|
|
192
|
+
for cursor in citer.iterate_cursors(complete_all=True):
|
|
193
|
+
pos = cursor.position()
|
|
194
|
+
cursor.setPosition(max(0, pos - len16(prefix)), MM.KeepAnchor)
|
|
195
|
+
# Replace the prefix in one edit, so the tree only sees one change
|
|
196
|
+
if cursor.selectedText() == prefix:
|
|
197
|
+
cursor.insertText(comp.text)
|
|
198
|
+
else:
|
|
199
|
+
cursor.setPosition(pos)
|
|
200
|
+
cursor.insertText(comp.text[len(prefix) :])
|
|
201
|
+
self.completion_popup.hide()
|
|
202
|
+
return True
|
|
203
|
+
|
|
204
|
+
def select_previous(self) -> bool:
|
|
205
|
+
self.completion_popup.offset_current(-1)
|
|
206
|
+
return True
|
|
207
|
+
|
|
208
|
+
def select_next(self) -> bool:
|
|
209
|
+
self.completion_popup.offset_current(1)
|
|
210
|
+
return True
|
|
211
|
+
|
|
212
|
+
def dismiss(self) -> bool:
|
|
213
|
+
"""Hide the popup, and consume the key"""
|
|
214
|
+
self.completion_popup.hide()
|
|
215
|
+
return True
|
|
216
|
+
|
|
217
|
+
def dismiss_and_pass_key(self) -> bool:
|
|
218
|
+
"""Hide the popup, and let the key through"""
|
|
219
|
+
self.completion_popup.hide()
|
|
220
|
+
return False
|
|
221
|
+
|
|
222
|
+
def handle_key_press(self, event: QtGui.QKeyEvent) -> bool:
|
|
223
|
+
# The keys only act on a showing popup, and otherwise go to the next handler
|
|
224
|
+
if not self.completion_popup.isVisible():
|
|
225
|
+
return False
|
|
226
|
+
# Keyed by int, since event.key() is an int and not every key is in the enum
|
|
227
|
+
actions: dict[int, Callable[[], bool]] = {
|
|
228
|
+
KEY.Key_Return: self.accept_item,
|
|
229
|
+
KEY.Key_Enter: self.accept_item,
|
|
230
|
+
KEY.Key_Tab: self.accept_item,
|
|
231
|
+
KEY.Key_Up: self.select_previous,
|
|
232
|
+
KEY.Key_Down: self.select_next,
|
|
233
|
+
KEY.Key_Escape: self.dismiss,
|
|
234
|
+
KEY.Key_Backspace: self.dismiss_and_pass_key,
|
|
235
|
+
}
|
|
236
|
+
vim_keys = self.options["vim_completion_keys"]
|
|
237
|
+
if vim_keys and event.modifiers() & MOD.ControlModifier:
|
|
238
|
+
actions.update(
|
|
239
|
+
{
|
|
240
|
+
KEY.Key_Y: self.accept_item,
|
|
241
|
+
KEY.Key_P: self.select_previous,
|
|
242
|
+
KEY.Key_N: self.select_next,
|
|
243
|
+
}
|
|
244
|
+
)
|
|
245
|
+
action = actions.get(event.key())
|
|
246
|
+
return action() if action is not None else False
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
__all__ = [
|
|
250
|
+
"Completion",
|
|
251
|
+
"CompletionContext",
|
|
252
|
+
"CompletionPopup",
|
|
253
|
+
"Provider",
|
|
254
|
+
"TabCompletion",
|
|
255
|
+
"context_at_cursor",
|
|
256
|
+
"identifier_before",
|
|
257
|
+
]
|