QCodeSitter 0.1.0__tar.gz → 0.2.0__tar.gz

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.
Files changed (93) hide show
  1. qcodesitter-0.2.0/.github/workflows/publish.yml +22 -0
  2. qcodesitter-0.2.0/.gitignore +11 -0
  3. {qcodesitter-0.1.0/src/QCodeSitter.egg-info → qcodesitter-0.2.0}/PKG-INFO +1 -1
  4. qcodesitter-0.2.0/build_venv_and_kickoff.sh +47 -0
  5. qcodesitter-0.2.0/examples/demo.py +26 -0
  6. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/pyproject.toml +4 -2
  7. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/behaviors/highlight_matching_selection.py +9 -2
  8. qcodesitter-0.2.0/src/QCodeSitter/behaviors/line_commands.py +120 -0
  9. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/behaviors/multi_cursor_paint.py +1 -1
  10. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/behaviors/syntax_highlighting.py +6 -0
  11. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/cursors/multi.py +25 -4
  12. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/defaults.py +2 -0
  13. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/gui_colors.py +1 -1
  14. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/themes.py +5 -4
  15. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/tree_manager.py +26 -20
  16. {qcodesitter-0.1.0 → qcodesitter-0.2.0/src/QCodeSitter.egg-info}/PKG-INFO +1 -1
  17. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter.egg-info/SOURCES.txt +40 -1
  18. qcodesitter-0.2.0/src/QCodeSitter.egg-info/scm_file_list.json +87 -0
  19. qcodesitter-0.2.0/src/QCodeSitter.egg-info/scm_version.json +8 -0
  20. qcodesitter-0.2.0/tests/__init__.py +1 -0
  21. qcodesitter-0.2.0/tests/behaviors/__init__.py +1 -0
  22. qcodesitter-0.2.0/tests/behaviors/test_behaviors.py +425 -0
  23. qcodesitter-0.2.0/tests/behaviors/test_code_folding.py +327 -0
  24. qcodesitter-0.2.0/tests/behaviors/test_comment_toggle.py +226 -0
  25. qcodesitter-0.2.0/tests/behaviors/test_gutters.py +99 -0
  26. qcodesitter-0.2.0/tests/behaviors/test_highlight_matching.py +109 -0
  27. qcodesitter-0.2.0/tests/behaviors/test_tab_completion.py +325 -0
  28. qcodesitter-0.2.0/tests/conftest.py +122 -0
  29. qcodesitter-0.2.0/tests/editor/__init__.py +1 -0
  30. qcodesitter-0.2.0/tests/editor/test_clipboard.py +127 -0
  31. qcodesitter-0.2.0/tests/editor/test_cursors.py +276 -0
  32. qcodesitter-0.2.0/tests/editor/test_editing_keys.py +77 -0
  33. qcodesitter-0.2.0/tests/editor/test_editor_core.py +497 -0
  34. qcodesitter-0.2.0/tests/editor/test_line_edits.py +124 -0
  35. qcodesitter-0.2.0/tests/editor/test_multi_cursor.py +387 -0
  36. qcodesitter-0.2.0/tests/editor/test_options.py +310 -0
  37. qcodesitter-0.2.0/tests/editor/test_shortcuts.py +61 -0
  38. qcodesitter-0.2.0/tests/editor/test_utils.py +73 -0
  39. qcodesitter-0.2.0/tests/fuzz/__init__.py +0 -0
  40. qcodesitter-0.2.0/tests/fuzz/test_fuzz.py +410 -0
  41. qcodesitter-0.2.0/tests/helpers.py +130 -0
  42. qcodesitter-0.2.0/tests/integration/__init__.py +0 -0
  43. qcodesitter-0.2.0/tests/integration/test_demo.py +26 -0
  44. qcodesitter-0.2.0/tests/integration/test_preditor_interface.py +263 -0
  45. qcodesitter-0.2.0/tests/syntax/__init__.py +1 -0
  46. qcodesitter-0.2.0/tests/syntax/test_incremental.py +182 -0
  47. qcodesitter-0.2.0/tests/syntax/test_languages.py +125 -0
  48. qcodesitter-0.2.0/tests/syntax/test_queries.py +138 -0
  49. qcodesitter-0.2.0/tests/syntax/test_syntax.py +387 -0
  50. qcodesitter-0.2.0/tests/syntax/test_themes.py +78 -0
  51. qcodesitter-0.2.0/tox.ini +73 -0
  52. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/LICENSE.txt +0 -0
  53. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/README.md +0 -0
  54. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/setup.cfg +0 -0
  55. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/__init__.py +0 -0
  56. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/behaviors/__init__.py +0 -0
  57. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/behaviors/auto_bracket.py +0 -0
  58. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/behaviors/code_folding.py +0 -0
  59. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/behaviors/comment_toggle.py +0 -0
  60. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/behaviors/highlight_matching_brackets.py +0 -0
  61. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/behaviors/line_numbers.py +0 -0
  62. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/behaviors/overscroll.py +0 -0
  63. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/behaviors/smart_indent.py +0 -0
  64. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/behaviors/tab_completion/__init__.py +0 -0
  65. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/behaviors/tab_completion/popup.py +0 -0
  66. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/behaviors/tab_completion/providers.py +0 -0
  67. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/behaviors/tab_completion/types.py +0 -0
  68. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/code_editor.py +0 -0
  69. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/cursors/__init__.py +0 -0
  70. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/cursors/clipboard.py +0 -0
  71. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/cursors/iterator.py +0 -0
  72. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/cursors/line_edits.py +0 -0
  73. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/cursors/state.py +0 -0
  74. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/cursors/tracked.py +0 -0
  75. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/editing_keys.py +0 -0
  76. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/editor_options.py +0 -0
  77. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/gutters.py +0 -0
  78. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/integrations/__init__.py +0 -0
  79. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/integrations/preditor_interface.py +0 -0
  80. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/languages/__init__.py +0 -0
  81. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/languages/base.py +0 -0
  82. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/languages/python.py +0 -0
  83. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/languages/python_highlights.py +0 -0
  84. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/py.typed +0 -0
  85. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/queries/__init__.py +0 -0
  86. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/queries/predicates.py +0 -0
  87. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/syntax_specs.py +0 -0
  88. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/tracked_document.py +0 -0
  89. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter/utils.py +0 -0
  90. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter.egg-info/dependency_links.txt +0 -0
  91. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter.egg-info/entry_points.txt +0 -0
  92. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter.egg-info/requires.txt +0 -0
  93. {qcodesitter-0.1.0 → qcodesitter-0.2.0}/src/QCodeSitter.egg-info/top_level.txt +0 -0
@@ -0,0 +1,22 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ environment: pypi
11
+ permissions:
12
+ id-token: write # PyPI trusted publishing, no API token needed
13
+ steps:
14
+ - uses: actions/checkout@v7
15
+ with:
16
+ fetch-depth: 0 # setuptools-scm needs the tags to compute the version
17
+ - uses: actions/setup-python@v7
18
+ with:
19
+ python-version: "3.13"
20
+ - run: python -m pip install build
21
+ - run: python -m build
22
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,11 @@
1
+ .benchmarks
2
+ .claude
3
+ .desloppify
4
+ .hypothesis
5
+ .pytest_cache
6
+ .ruff_cache
7
+ .tokensave
8
+ .venv
9
+ USEFUL
10
+ __pycache__
11
+ *.egg-info
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: QCodeSitter
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: A tree-sitter powered code editor widget for Qt
5
5
  Author-email: Tyler Fox <tyler@blur.com>
6
6
  License-Expression: MIT
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env bash
2
+ set -e
3
+
4
+ # Resolve the directory this script lives in
5
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+ VENV_DIR="$SCRIPT_DIR/.venv"
7
+
8
+ # Pick a Python executable. Default to the py launcher
9
+ if command -v py >/dev/null 2>&1; then
10
+ PYTHON="py -3.13"
11
+ elif command -v python3 >/dev/null 2>&1; then
12
+ PYTHON=python3
13
+ elif command -v python >/dev/null 2>&1; then
14
+ PYTHON=python
15
+ else
16
+ echo "Error: Python not found on PATH"
17
+ exit 1
18
+ fi
19
+
20
+ activate_venv() {
21
+ if [[ -f "$VENV_DIR/bin/activate" ]]; then
22
+ # macOS / Linux
23
+ source "$VENV_DIR/bin/activate"
24
+ elif [[ -f "$VENV_DIR/Scripts/activate" ]]; then
25
+ # Windows (Git Bash / MSYS)
26
+ source "$VENV_DIR/Scripts/activate"
27
+ else
28
+ echo "Error: Could not find virtualenv activate script"
29
+ exit 1
30
+ fi
31
+ }
32
+
33
+ # Create virtual environment if it doesn't exist
34
+ if [[ ! -d "$VENV_DIR" ]]; then
35
+ echo "Building Virtualenv..."
36
+ $PYTHON -m venv "$VENV_DIR"
37
+
38
+ activate_venv
39
+ # Upgrade pip and install PySide6
40
+ python -m pip install --upgrade pip
41
+ pip install PySide6 Preditor[cli]
42
+ pip install -e "${SCRIPT_DIR}[preditor]"
43
+ else
44
+ activate_venv
45
+ fi
46
+
47
+ python "${SCRIPT_DIR}/examples/demo.py"
@@ -0,0 +1,26 @@
1
+ """Open a window with a fully featured editor. Run with `python examples/demo.py`"""
2
+
3
+ import sys
4
+
5
+ from Qt import QtWidgets
6
+
7
+ from QCodeSitter import CodeEditor, default_options, install_default_behaviors
8
+
9
+
10
+ def build_window() -> QtWidgets.QMainWindow:
11
+ win = QtWidgets.QMainWindow()
12
+ editor = CodeEditor(default_options(), parent=win)
13
+ install_default_behaviors(editor, optional=True)
14
+ win.setCentralWidget(editor)
15
+ return win
16
+
17
+
18
+ def main() -> None:
19
+ app = QtWidgets.QApplication(sys.argv)
20
+ win = build_window()
21
+ win.show()
22
+ app.exec()
23
+
24
+
25
+ if __name__ == "__main__":
26
+ main()
@@ -1,10 +1,10 @@
1
1
  [build-system]
2
- requires = ["setuptools>=77"]
2
+ requires = ["setuptools>=77", "setuptools-scm>=8"]
3
3
  build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "QCodeSitter"
7
- version = "0.1.0"
7
+ dynamic = ["version"] # from the git tag, via setuptools-scm
8
8
  description = "A tree-sitter powered code editor widget for Qt"
9
9
  requires-python = ">=3.10"
10
10
  # Qt.py needs a Qt binding: install one yourself, or use the pyside6 extra.
@@ -45,6 +45,8 @@ where = ["src"]
45
45
  [tool.setuptools.package-data]
46
46
  QCodeSitter = ["py.typed"]
47
47
 
48
+ [tool.setuptools_scm]
49
+
48
50
  [tool.mypy]
49
51
  python_version = "3.10"
50
52
 
@@ -22,6 +22,8 @@ class HighlightMatchingSelection(Behavior):
22
22
  super().__init__(editor)
23
23
  self.watch_options({"colors": self._set_colors})
24
24
  self.connect(self.editor.selectionChanged, self.highlight_occurrences)
25
+ # Ctrl+D adds cursors without changing the primary selection
26
+ self.connect(self.editor.cursors.changed, self.highlight_occurrences)
25
27
 
26
28
  def _set_colors(self, colors: GuiColors) -> None:
27
29
  self._match_hl = gui_color(colors, "match_hl")
@@ -41,6 +43,12 @@ class HighlightMatchingSelection(Behavior):
41
43
  fmt.setBackground(self._match_hl)
42
44
  doc = self.editor.document()
43
45
  search_cursor = QtGui.QTextCursor(doc)
46
+ # Qt or MultiCursorPaint already draws the cursors' own selections, and
47
+ # a highlight over them would hide which occurrences are selected
48
+ selected = {
49
+ (state.selectionStart(), state.selectionEnd())
50
+ for state in (cursor, *self.editor.cursors.secondary)
51
+ }
44
52
  case_sensitive = QtGui.QTextDocument.FindFlag.FindCaseSensitively
45
53
 
46
54
  while True:
@@ -48,9 +56,8 @@ class HighlightMatchingSelection(Behavior):
48
56
  if search_cursor.isNull():
49
57
  break
50
58
 
51
- # Qt already highlights the selection itself
52
59
  found = (search_cursor.selectionStart(), search_cursor.selectionEnd())
53
- if found != (cursor.selectionStart(), cursor.selectionEnd()):
60
+ if found not in selected:
54
61
  selection = QtWidgets.QTextEdit.ExtraSelection()
55
62
  selection.cursor = search_cursor
56
63
  selection.format = fmt
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from ..cursors.state import CursorState, primary_first
6
+ from ..tracked_document import len16
7
+ from ..utils import MM, add_menu_action, make_shortcut_action
8
+ from . import Behavior
9
+
10
+ if TYPE_CHECKING:
11
+ from Qt import QtCore, QtGui, QtWidgets
12
+
13
+ MOVE_UP = ("Move Lines Up", "Ctrl+Shift+Up")
14
+ MOVE_DOWN = ("Move Lines Down", "Ctrl+Shift+Down")
15
+ DUPLICATE = ("Duplicate", "Ctrl+Shift+D")
16
+
17
+
18
+ class LineCommands(Behavior):
19
+ """Move the lines under the cursors up or down, and duplicate the selection
20
+ or the current line, like PrEditor's QScintilla keys
21
+ """
22
+
23
+ def _commands(self):
24
+ return (
25
+ (*MOVE_UP, lambda: self.move_lines(up=True)),
26
+ (*MOVE_DOWN, lambda: self.move_lines(up=False)),
27
+ (*DUPLICATE, self.duplicate),
28
+ )
29
+
30
+ def get_hotkeys(self) -> list[QtGui.QAction]:
31
+ return [
32
+ make_shortcut_action(self.editor, shortcut, slot, label)
33
+ for label, shortcut, slot in self._commands()
34
+ ]
35
+
36
+ def add_menu_actions(
37
+ self, menu: QtWidgets.QMenu, pos: QtCore.QPoint | None
38
+ ) -> None:
39
+ for label, shortcut, slot in self._commands():
40
+ add_menu_action(menu, label, slot, shortcut)
41
+
42
+ def duplicate(self) -> None:
43
+ """Copy each selection after itself and select the copy, or copy the
44
+ line of a cursor without a selection below it
45
+ """
46
+ with self.editor.edit_session():
47
+ for cursor in self.editor.citer.iterate_cursors(complete_all=True):
48
+ if cursor.hasSelection():
49
+ text = cursor.selection().toPlainText()
50
+ end = cursor.selectionEnd()
51
+ cursor.setPosition(end)
52
+ cursor.insertText(text)
53
+ cursor.setPosition(end)
54
+ cursor.setPosition(end + len16(text), MM.KeepAnchor)
55
+ else:
56
+ block = cursor.block()
57
+ column = cursor.positionInBlock()
58
+ cursor.setPosition(block.position() + len16(block.text()))
59
+ cursor.insertText("\n" + block.text())
60
+ cursor.setPosition(block.next().position() + column)
61
+
62
+ def move_lines(self, up: bool) -> None:
63
+ """Swap the lines under the cursors with the line above or below them
64
+
65
+ Nothing moves if any of the lines is already at the edge of the document
66
+ """
67
+ doc = self.editor.document()
68
+ states, primary_index = self.editor.cursors.in_document_order()
69
+
70
+ # Group the cursors whose lines touch, as (first line, last line, states)
71
+ groups: list[tuple[int, int, list[CursorState]]] = []
72
+ for state in states:
73
+ first = doc.findBlock(state.selectionStart()).blockNumber()
74
+ end_block = doc.findBlock(state.selectionEnd())
75
+ last = end_block.blockNumber()
76
+ # A selection ending at the start of a line doesn't take that line
77
+ if last > first and state.selectionEnd() == end_block.position():
78
+ last -= 1
79
+ if groups and first <= groups[-1][1] + 1:
80
+ groups[-1] = (groups[-1][0], max(groups[-1][1], last), groups[-1][2])
81
+ groups[-1][2].append(state)
82
+ else:
83
+ groups.append((first, last, [state]))
84
+
85
+ if up and groups[0][0] == 0:
86
+ return
87
+ if not up and groups[-1][1] == doc.blockCount() - 1:
88
+ return
89
+
90
+ moved: list[CursorState] = []
91
+ with self.editor.edit_session():
92
+ cursor = self.editor.textCursor()
93
+ cursor.beginEditBlock()
94
+ try:
95
+ for first, last, group in groups:
96
+ span_first, span_last = (
97
+ (first - 1, last) if up else (first, last + 1)
98
+ )
99
+ start_block = doc.findBlockByNumber(span_first)
100
+ end_block = doc.findBlockByNumber(span_last)
101
+ cursor.setPosition(start_block.position())
102
+ cursor.setPosition(
103
+ end_block.position() + len16(end_block.text()), MM.KeepAnchor
104
+ )
105
+ lines = cursor.selection().toPlainText().split("\n")
106
+ # The swapped line's length plus its newline is how far the others move
107
+ if up:
108
+ lines.append(lines.pop(0))
109
+ shift = -len16(lines[-1]) - 1
110
+ else:
111
+ lines.insert(0, lines.pop())
112
+ shift = len16(lines[0]) + 1
113
+ # The text keeps its length, so the later groups stay put
114
+ cursor.insertText("\n".join(lines))
115
+ moved += [
116
+ CursorState(s.anchor + shift, s.position + shift) for s in group
117
+ ]
118
+ finally:
119
+ cursor.endEditBlock()
120
+ self.editor.cursors.replace_all(primary_first(moved, primary_index))
@@ -67,7 +67,7 @@ class MultiCursorPaint(Behavior):
67
67
  states = [*self.editor.cursors.secondary, self.editor.cursors.primary()]
68
68
 
69
69
  fmt = QtGui.QTextCharFormat()
70
- fmt.setBackground(self.secondary_selection_color.lighter(150))
70
+ fmt.setBackground(self.secondary_selection_color)
71
71
  doc = self.editor.document()
72
72
  selections = []
73
73
  for state in states:
@@ -81,6 +81,12 @@ class TreeSitterHighlighter(QtGui.QSyntaxHighlighter):
81
81
  block = self.currentBlock()
82
82
  if tree is None or not block.isValid():
83
83
  return
84
+ if self.tree_manager.reparse_pending:
85
+ # Qt highlights every block between the first and last edit of an
86
+ # edit block. Keep their formats, the reparse redoes the edited rows
87
+ for fmt_range in block.layout().formats():
88
+ self.setFormat(fmt_range.start, fmt_range.length, fmt_range.format)
89
+ return
84
90
 
85
91
  # Qt positions are UTF-16 code units, but python's len counts code points
86
92
  # so they differ when the text contains characters outside the BMP
@@ -126,13 +126,34 @@ class MultiCursor(QtCore.QObject):
126
126
  search_text = cursor.selectedText()
127
127
  all_cursors = self._primary_first()
128
128
  next_pos = self._find(search_text, max(c.selectionEnd() for c in all_cursors))
129
+ length = len16(search_text)
129
130
  if next_pos < 0:
130
- # Wrap around to the start, but only up to the first cursor
131
+ # Wrap around to the start, skipping occurrences that have a cursor
132
+ taken = {c.selectionStart() for c in all_cursors}
131
133
  next_pos = self._find(search_text, 0)
132
- if next_pos < 0 or next_pos >= min(c.selectionStart() for c in all_cursors):
134
+ while next_pos in taken:
135
+ next_pos = self._find(search_text, next_pos + length)
136
+ if next_pos < 0:
133
137
  return False
134
- end = next_pos + len16(search_text)
135
- return self._add([*all_cursors, CursorState(next_pos, end)])
138
+ end = next_pos + length
139
+ added = self._add([*all_cursors, CursorState(next_pos, end)])
140
+ if added:
141
+ self._scroll_to(end)
142
+ return added
143
+
144
+ def _scroll_to(self, position: int) -> None:
145
+ """Scroll to show a position, leaving the primary cursor where it is"""
146
+ # Qt only scrolls to the primary cursor, so borrow it to find the scroll
147
+ editor = self.editor
148
+ primary = editor.textCursor()
149
+ bars = (editor.horizontalScrollBar(), editor.verticalScrollBar())
150
+ editor.setTextCursor(
151
+ CursorState(position, position).build_cursor(editor.document())
152
+ )
153
+ values = [bar.value() for bar in bars]
154
+ editor.setTextCursor(primary)
155
+ for bar, value in zip(bars, values):
156
+ bar.setValue(value)
136
157
 
137
158
  def _find(self, search_text: str, start_pos: int) -> int:
138
159
  """Find the position of search_text after start_pos, or -1 if there is none
@@ -12,6 +12,7 @@ from .behaviors.code_folding import CodeFolding
12
12
  from .behaviors.comment_toggle import CommentToggle
13
13
  from .behaviors.highlight_matching_brackets import HighlightMatchingBrackets
14
14
  from .behaviors.highlight_matching_selection import HighlightMatchingSelection
15
+ from .behaviors.line_commands import LineCommands
15
16
  from .behaviors.line_numbers import LineNumbers
16
17
  from .behaviors.multi_cursor_paint import MultiCursorPaint
17
18
  from .behaviors.overscroll import Overscroll
@@ -38,6 +39,7 @@ DEFAULT_BEHAVIORS: tuple[type[Behavior], ...] = (
38
39
  MultiCursorPaint,
39
40
  CodeFolding,
40
41
  CommentToggle,
42
+ LineCommands,
41
43
  )
42
44
 
43
45
  # The built-in behaviors that are off by default
@@ -31,7 +31,7 @@ GUI_COLOR_DEFAULTS: GuiColors = {
31
31
  "primary_cursor": "#FFFFFFFF",
32
32
  "secondary_cursor": "#B4B4B4",
33
33
  "selection": "#094771",
34
- "secondary_selection": "#B4B4B4",
34
+ "secondary_selection": "#264F78",
35
35
  }
36
36
 
37
37
  # Gui color name -> its default, made from the other colors
@@ -36,6 +36,7 @@ DARK_THEME: Theme = {
36
36
  "red": "#FF5555",
37
37
  "orange": "#FFB86C",
38
38
  "yellow": "#F1FA8C",
39
+ "faint_yellow": "#40F1FA8C",
39
40
  "green": "#50fa7b",
40
41
  "purple": "#BD93F9",
41
42
  "cyan": "#8BE9FD",
@@ -72,14 +73,14 @@ DARK_THEME: Theme = {
72
73
  "gutter_fg": "xlight_slate",
73
74
  "hl_dim": "mid_light_slate",
74
75
  "manual_fold": "orange",
75
- "match_hl": "bright_yellow",
76
+ "match_hl": "faint_yellow",
76
77
  "menu": "dark_slate",
77
78
  "outside_bg": "dark_slate",
78
79
  "pair_hl": "mid_gray",
79
80
  "primary_cursor": "bright_white",
80
81
  "secondary_cursor": "half_mid_gray",
81
82
  "selection": "mid_light_slate",
82
- "secondary_selection": "half_mid_gray",
83
+ "secondary_selection": "light_slate",
83
84
  },
84
85
 
85
86
  "syntax" : {
@@ -173,14 +174,14 @@ LIGHT_THEME: Theme = {
173
174
  "gutter_fg": "fg",
174
175
  "hl_dim": "filled_blue",
175
176
  "manual_fold": "yellow",
176
- "match_hl": "filled_green",
177
+ "match_hl": "bg_green",
177
178
  "menu": "bg4",
178
179
  "outside_bg": "bg_grey",
179
180
  "pair_hl": "bg_yellow",
180
181
  "primary_cursor": "true_black",
181
182
  "secondary_cursor": "true_black",
182
183
  "selection": "filled_blue",
183
- "secondary_selection": "blue",
184
+ "secondary_selection": "filled_blue",
184
185
  },
185
186
 
186
187
  "syntax" : {
@@ -26,8 +26,8 @@ class TreeManager(QtCore.QObject):
26
26
  # Whether an edit was made while paused, so unpausing has to reparse
27
27
  self._reparse_pending = False
28
28
 
29
- # The (first, last) rows touched by edits since the last parse
30
- self._edit_rows: tuple[int, int] | None = None
29
+ # The (first, last) rows of each edit since the last parse
30
+ self._edit_rows: list[tuple[int, int]] = []
31
31
  # The (first, last) row ranges that changed in the last parse
32
32
  # None means the whole document changed
33
33
  self.changed_rows: list[tuple[int, int]] | None = None
@@ -42,6 +42,11 @@ class TreeManager(QtCore.QObject):
42
42
  def is_paused(self) -> bool:
43
43
  return self._pause_depth > 0
44
44
 
45
+ @property
46
+ def reparse_pending(self) -> bool:
47
+ """Whether edits are held by a pause, and the tree is out of date"""
48
+ return self._pause_depth > 0 and self._reparse_pending
49
+
45
50
  def set_language(self, language: Language | None) -> None:
46
51
  """Change the language, and parse the document again"""
47
52
  self._set_parser(language)
@@ -51,7 +56,7 @@ class TreeManager(QtCore.QObject):
51
56
  def _set_parser(self, language: Language | None) -> None:
52
57
  self.parser = Parser(language) if language is not None else None
53
58
  self.tree = None
54
- self._edit_rows = None
59
+ self._edit_rows = []
55
60
  self.changed_rows = None
56
61
 
57
62
  def treesitter_source_callback(self, _byte_offset: int, ts_point: Point) -> bytes:
@@ -73,7 +78,7 @@ class TreeManager(QtCore.QObject):
73
78
  def parse_from_scratch(self) -> None:
74
79
  """Parse the whole document from scratch. Without a language there is no tree"""
75
80
  self.tree = self._parse(self.parser) if self.parser is not None else None
76
- self._edit_rows = None
81
+ self._edit_rows = []
77
82
  self.changed_rows = None
78
83
  self.reparsed.emit()
79
84
 
@@ -97,20 +102,22 @@ class TreeManager(QtCore.QObject):
97
102
  def _track_edit_rows(
98
103
  self, start_row: int, old_end_row: int, new_end_row: int
99
104
  ) -> None:
100
- """Grow the span of edited rows to include this edit
105
+ """Add this edit's rows to the edited spans
101
106
 
102
- The span is kept in the current document's rows, so rows after
103
- the edit shift by the number of lines it added or removed
107
+ Spans are kept separate, so edits far apart (ie: multi-cursor) don't
108
+ mark every row between them. They're kept in the current document's
109
+ rows, so earlier spans shift by the lines this edit added or removed
104
110
  """
105
- if self._edit_rows is None:
106
- self._edit_rows = (start_row, new_end_row)
107
- return
108
- lo, hi = self._edit_rows
109
- if hi > old_end_row:
110
- hi += new_end_row - old_end_row
111
- else:
112
- hi = new_end_row
113
- self._edit_rows = (min(lo, start_row), hi)
111
+ delta = new_end_row - old_end_row
112
+
113
+ def shift(row: int) -> int:
114
+ if row > old_end_row:
115
+ return row + delta
116
+ # A row inside a removed range collapses to the edit's end
117
+ return min(row, new_end_row) if row > start_row else row
118
+
119
+ self._edit_rows = [(shift(lo), shift(hi)) for lo, hi in self._edit_rows]
120
+ self._edit_rows.append((start_row, new_end_row))
114
121
 
115
122
  def apply_edit(
116
123
  self,
@@ -145,7 +152,7 @@ class TreeManager(QtCore.QObject):
145
152
  return
146
153
 
147
154
  if self.parser is None:
148
- self._edit_rows = None
155
+ self._edit_rows = []
149
156
  return
150
157
 
151
158
  old_tree = self.tree
@@ -159,10 +166,9 @@ class TreeManager(QtCore.QObject):
159
166
  (r.start_point.row, r.end_point.row)
160
167
  for r in old_tree.changed_ranges(self.tree)
161
168
  ]
162
- if self._edit_rows is not None:
163
- rows.append(self._edit_rows)
169
+ rows.extend(self._edit_rows)
164
170
  self.changed_rows = rows
165
- self._edit_rows = None
171
+ self._edit_rows = []
166
172
  self.reparsed.emit()
167
173
 
168
174
  def node_at_byte(self, byte_offset: int) -> Node | None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: QCodeSitter
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: A tree-sitter powered code editor widget for Qt
5
5
  Author-email: Tyler Fox <tyler@blur.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,11 @@
1
+ .gitignore
1
2
  LICENSE.txt
2
3
  README.md
4
+ build_venv_and_kickoff.sh
3
5
  pyproject.toml
6
+ tox.ini
7
+ .github/workflows/publish.yml
8
+ examples/demo.py
4
9
  src/QCodeSitter/__init__.py
5
10
  src/QCodeSitter/code_editor.py
6
11
  src/QCodeSitter/defaults.py
@@ -19,6 +24,8 @@ src/QCodeSitter.egg-info/SOURCES.txt
19
24
  src/QCodeSitter.egg-info/dependency_links.txt
20
25
  src/QCodeSitter.egg-info/entry_points.txt
21
26
  src/QCodeSitter.egg-info/requires.txt
27
+ src/QCodeSitter.egg-info/scm_file_list.json
28
+ src/QCodeSitter.egg-info/scm_version.json
22
29
  src/QCodeSitter.egg-info/top_level.txt
23
30
  src/QCodeSitter/behaviors/__init__.py
24
31
  src/QCodeSitter/behaviors/auto_bracket.py
@@ -26,6 +33,7 @@ src/QCodeSitter/behaviors/code_folding.py
26
33
  src/QCodeSitter/behaviors/comment_toggle.py
27
34
  src/QCodeSitter/behaviors/highlight_matching_brackets.py
28
35
  src/QCodeSitter/behaviors/highlight_matching_selection.py
36
+ src/QCodeSitter/behaviors/line_commands.py
29
37
  src/QCodeSitter/behaviors/line_numbers.py
30
38
  src/QCodeSitter/behaviors/multi_cursor_paint.py
31
39
  src/QCodeSitter/behaviors/overscroll.py
@@ -49,4 +57,35 @@ src/QCodeSitter/languages/base.py
49
57
  src/QCodeSitter/languages/python.py
50
58
  src/QCodeSitter/languages/python_highlights.py
51
59
  src/QCodeSitter/queries/__init__.py
52
- src/QCodeSitter/queries/predicates.py
60
+ src/QCodeSitter/queries/predicates.py
61
+ tests/__init__.py
62
+ tests/conftest.py
63
+ tests/helpers.py
64
+ tests/behaviors/__init__.py
65
+ tests/behaviors/test_behaviors.py
66
+ tests/behaviors/test_code_folding.py
67
+ tests/behaviors/test_comment_toggle.py
68
+ tests/behaviors/test_gutters.py
69
+ tests/behaviors/test_highlight_matching.py
70
+ tests/behaviors/test_tab_completion.py
71
+ tests/editor/__init__.py
72
+ tests/editor/test_clipboard.py
73
+ tests/editor/test_cursors.py
74
+ tests/editor/test_editing_keys.py
75
+ tests/editor/test_editor_core.py
76
+ tests/editor/test_line_edits.py
77
+ tests/editor/test_multi_cursor.py
78
+ tests/editor/test_options.py
79
+ tests/editor/test_shortcuts.py
80
+ tests/editor/test_utils.py
81
+ tests/fuzz/__init__.py
82
+ tests/fuzz/test_fuzz.py
83
+ tests/integration/__init__.py
84
+ tests/integration/test_demo.py
85
+ tests/integration/test_preditor_interface.py
86
+ tests/syntax/__init__.py
87
+ tests/syntax/test_incremental.py
88
+ tests/syntax/test_languages.py
89
+ tests/syntax/test_queries.py
90
+ tests/syntax/test_syntax.py
91
+ tests/syntax/test_themes.py