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
QCodeSitter/defaults.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""The standard editor setup, shared by the demo, the PrEditor plugin and the tests"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from Qt import QtGui
|
|
8
|
+
from tree_sitter import Language
|
|
9
|
+
|
|
10
|
+
from .behaviors.auto_bracket import AutoBracket
|
|
11
|
+
from .behaviors.code_folding import CodeFolding
|
|
12
|
+
from .behaviors.comment_toggle import CommentToggle
|
|
13
|
+
from .behaviors.highlight_matching_brackets import HighlightMatchingBrackets
|
|
14
|
+
from .behaviors.highlight_matching_selection import HighlightMatchingSelection
|
|
15
|
+
from .behaviors.line_numbers import LineNumbers
|
|
16
|
+
from .behaviors.multi_cursor_paint import MultiCursorPaint
|
|
17
|
+
from .behaviors.overscroll import Overscroll
|
|
18
|
+
from .behaviors.smart_indent import SmartIndent
|
|
19
|
+
from .behaviors.syntax_highlighting import SyntaxHighlighting
|
|
20
|
+
from .behaviors.tab_completion import TabCompletion
|
|
21
|
+
from .behaviors.tab_completion.providers import IdentifierProvider
|
|
22
|
+
from .editor_options import EditorOptions
|
|
23
|
+
from .themes import DARK_THEME, Theme, read_theme
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from .behaviors import Behavior
|
|
27
|
+
from .code_editor import CodeEditor
|
|
28
|
+
|
|
29
|
+
# The built-in behaviors that install_default_behaviors adds, along with
|
|
30
|
+
# TabCompletion, which needs its providers
|
|
31
|
+
DEFAULT_BEHAVIORS: tuple[type[Behavior], ...] = (
|
|
32
|
+
SyntaxHighlighting,
|
|
33
|
+
SmartIndent,
|
|
34
|
+
HighlightMatchingBrackets,
|
|
35
|
+
HighlightMatchingSelection,
|
|
36
|
+
LineNumbers,
|
|
37
|
+
Overscroll,
|
|
38
|
+
MultiCursorPaint,
|
|
39
|
+
CodeFolding,
|
|
40
|
+
CommentToggle,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# The built-in behaviors that are off by default
|
|
44
|
+
OPTIONAL_BEHAVIORS: tuple[type[Behavior], ...] = (AutoBracket,)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def default_options(
|
|
48
|
+
theme: Theme = DARK_THEME,
|
|
49
|
+
language: Language | None = None,
|
|
50
|
+
font: QtGui.QFont | None = None,
|
|
51
|
+
) -> EditorOptions:
|
|
52
|
+
"""Make a new set of options for editing python, or another language
|
|
53
|
+
|
|
54
|
+
The other options take their values from OPTION_DEFAULTS
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
theme: The color theme
|
|
58
|
+
language: The tree-sitter language. Defaults to python, which needs
|
|
59
|
+
the tree-sitter-python package
|
|
60
|
+
font: Defaults to an 11pt system font
|
|
61
|
+
"""
|
|
62
|
+
if language is None:
|
|
63
|
+
import tree_sitter_python
|
|
64
|
+
|
|
65
|
+
language = Language(tree_sitter_python.language())
|
|
66
|
+
if font is None:
|
|
67
|
+
font = QtGui.QFontDatabase.systemFont(
|
|
68
|
+
QtGui.QFontDatabase.SystemFont.GeneralFont
|
|
69
|
+
)
|
|
70
|
+
font.setPointSize(11)
|
|
71
|
+
colors, syntax = read_theme(theme)
|
|
72
|
+
return EditorOptions(
|
|
73
|
+
{"language": language, "syntax": syntax, "colors": colors, "font": font}
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def install_tab_completion(editor: CodeEditor) -> TabCompletion:
|
|
78
|
+
"""Add tab completion, completing the names defined in the document"""
|
|
79
|
+
completion = editor.add_behavior(TabCompletion)
|
|
80
|
+
completion.add_provider(IdentifierProvider)
|
|
81
|
+
return completion
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def install_default_behaviors(editor: CodeEditor, optional: bool = False) -> None:
|
|
85
|
+
"""Add tab completion and DEFAULT_BEHAVIORS to an editor
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
editor: The editor to add the behaviors to
|
|
89
|
+
optional: Also add the OPTIONAL_BEHAVIORS
|
|
90
|
+
"""
|
|
91
|
+
install_tab_completion(editor)
|
|
92
|
+
for behavior_cls in DEFAULT_BEHAVIORS + (OPTIONAL_BEHAVIORS if optional else ()):
|
|
93
|
+
editor.add_behavior(behavior_cls)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""The editor's built-in keys: typing, deleting, moving, and the edit commands"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from .utils import KEY, MO, MOD
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from Qt import QtGui
|
|
12
|
+
|
|
13
|
+
from .code_editor import CodeEditor
|
|
14
|
+
|
|
15
|
+
# Delete key -> (move operation, move operation with Ctrl held)
|
|
16
|
+
_DELETE_OPS: dict[int, tuple[MO, MO]] = {
|
|
17
|
+
KEY.Key_Backspace: (MO.PreviousCharacter, MO.PreviousWord),
|
|
18
|
+
KEY.Key_Delete: (MO.NextCharacter, MO.NextWord),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
# Movement key -> (move operation, move operation with Ctrl held)
|
|
22
|
+
# None is smart home, which toggles between the indent and column 0
|
|
23
|
+
_MOVE_OPS: dict[int, tuple[MO | None, MO]] = {
|
|
24
|
+
KEY.Key_Left: (MO.Left, MO.WordLeft),
|
|
25
|
+
KEY.Key_Right: (MO.Right, MO.WordRight),
|
|
26
|
+
KEY.Key_Up: (MO.Up, MO.Up),
|
|
27
|
+
KEY.Key_Down: (MO.Down, MO.Down),
|
|
28
|
+
KEY.Key_Home: (None, MO.Start),
|
|
29
|
+
KEY.Key_End: (MO.EndOfLine, MO.End),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# The modifiers that can be held while typing text, besides Shift: the
|
|
33
|
+
# numpad, and AltGr, which is Ctrl+Alt on Windows and Group Switch elsewhere
|
|
34
|
+
_TEXT_MODIFIERS = MOD.ShiftModifier | MOD.KeypadModifier | MOD.GroupSwitchModifier
|
|
35
|
+
_ALTGR = MOD.ControlModifier | MOD.AltModifier
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class EditingKeys:
|
|
39
|
+
"""Applies the built-in editing keys to every cursor of an editor
|
|
40
|
+
|
|
41
|
+
The editor gives a key to its behaviors first, and to this after them
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, editor: CodeEditor) -> None:
|
|
45
|
+
self.editor = editor
|
|
46
|
+
# Whether the next word character joins the last one's undo step
|
|
47
|
+
self._can_join = False
|
|
48
|
+
# (modifier, action) pairs for the keys that run editor commands
|
|
49
|
+
self._command_keys: dict[int, tuple[tuple[MOD, Callable[[], None]], ...]] = {
|
|
50
|
+
KEY.Key_C: ((MOD.ControlModifier, editor.copy),),
|
|
51
|
+
KEY.Key_Insert: (
|
|
52
|
+
(MOD.ControlModifier, editor.copy),
|
|
53
|
+
(MOD.ShiftModifier, editor.paste),
|
|
54
|
+
),
|
|
55
|
+
KEY.Key_V: ((MOD.ControlModifier, editor.paste),),
|
|
56
|
+
KEY.Key_X: ((MOD.ControlModifier, editor.cut),),
|
|
57
|
+
KEY.Key_Z: (
|
|
58
|
+
(MOD.ControlModifier, editor.undo),
|
|
59
|
+
(MOD.ControlModifier | MOD.ShiftModifier, editor.redo),
|
|
60
|
+
),
|
|
61
|
+
KEY.Key_Y: ((MOD.ControlModifier, editor.redo),),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
def end_undo_step(self) -> None:
|
|
65
|
+
"""Make the next typing start a new undo step"""
|
|
66
|
+
self._can_join = False
|
|
67
|
+
|
|
68
|
+
def handle_key_press(self, event: QtGui.QKeyEvent) -> bool:
|
|
69
|
+
"""Apply an editing key to all the cursors
|
|
70
|
+
|
|
71
|
+
Returns whether the key was handled
|
|
72
|
+
"""
|
|
73
|
+
key = event.key()
|
|
74
|
+
modifiers = event.modifiers()
|
|
75
|
+
cursors = self.editor.cursors
|
|
76
|
+
|
|
77
|
+
# Escape leaves multi-cursor mode, or else goes on to the parent widget
|
|
78
|
+
if key == KEY.Key_Escape:
|
|
79
|
+
had_secondary = bool(cursors.secondary)
|
|
80
|
+
cursors.clear_secondary()
|
|
81
|
+
return had_secondary
|
|
82
|
+
|
|
83
|
+
if self._type_text(event):
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
if modifiers == MOD.ControlModifier and key == KEY.Key_A:
|
|
87
|
+
cursors.clear_secondary()
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
delete_ops = _DELETE_OPS.get(key)
|
|
91
|
+
if delete_ops is not None:
|
|
92
|
+
self.editor.delete(delete_ops[bool(modifiers & MOD.ControlModifier)])
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
# Alt+arrow combinations are hotkeys, like Ctrl+Alt+Up to add a cursor
|
|
96
|
+
if modifiers & MOD.AltModifier:
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
move_ops = _MOVE_OPS.get(key)
|
|
100
|
+
if move_ops is None:
|
|
101
|
+
return self._run_command_key(event)
|
|
102
|
+
select = bool(modifiers & MOD.ShiftModifier)
|
|
103
|
+
move_op = move_ops[bool(modifiers & MOD.ControlModifier)]
|
|
104
|
+
if move_op is None:
|
|
105
|
+
cursors.smart_home(select)
|
|
106
|
+
else:
|
|
107
|
+
cursors.move(move_op, select)
|
|
108
|
+
return True
|
|
109
|
+
|
|
110
|
+
def _type_text(self, event: QtGui.QKeyEvent) -> bool:
|
|
111
|
+
"""Insert the text of a typing key at every cursor
|
|
112
|
+
|
|
113
|
+
Returns whether the key typed text
|
|
114
|
+
"""
|
|
115
|
+
text = event.text()
|
|
116
|
+
typing = (
|
|
117
|
+
text
|
|
118
|
+
and text.isprintable()
|
|
119
|
+
and event.modifiers() & ~_TEXT_MODIFIERS in (MOD.NoModifier, _ALTGR)
|
|
120
|
+
)
|
|
121
|
+
if not typing:
|
|
122
|
+
self._can_join = False
|
|
123
|
+
return False
|
|
124
|
+
# Typing a word is a single undo step, so each character joins the last
|
|
125
|
+
is_word = text.isalnum() or text == "_"
|
|
126
|
+
self.editor.insert_text(text, join_edit=self._can_join and is_word)
|
|
127
|
+
self._can_join = is_word
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
def _run_command_key(self, event: QtGui.QKeyEvent) -> bool:
|
|
131
|
+
"""Run the editor command for Tab, Enter, or a clipboard/undo shortcut"""
|
|
132
|
+
key = event.key()
|
|
133
|
+
options = self.editor.options
|
|
134
|
+
if key == KEY.Key_Tab:
|
|
135
|
+
if options["indent_using_tabs"]:
|
|
136
|
+
self.editor.insert_text("\t")
|
|
137
|
+
else:
|
|
138
|
+
self.editor.insert_text(" " * options["space_indent_width"])
|
|
139
|
+
return True
|
|
140
|
+
# Key_Enter is the numpad
|
|
141
|
+
if key in (KEY.Key_Return, KEY.Key_Enter):
|
|
142
|
+
self.editor.insert_text("\n")
|
|
143
|
+
return True
|
|
144
|
+
# The modifiers must match exactly, so Ctrl+Shift+Z doesn't also undo
|
|
145
|
+
modifiers = event.modifiers() & ~MOD.KeypadModifier
|
|
146
|
+
for modifier, command in self._command_keys.get(key, ()):
|
|
147
|
+
if modifiers == modifier:
|
|
148
|
+
command()
|
|
149
|
+
return True
|
|
150
|
+
return False
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
5
|
+
from typing import (
|
|
6
|
+
TYPE_CHECKING,
|
|
7
|
+
Any,
|
|
8
|
+
Literal,
|
|
9
|
+
TypedDict,
|
|
10
|
+
get_origin,
|
|
11
|
+
get_type_hints,
|
|
12
|
+
overload,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from Qt import QtCore, QtGui
|
|
16
|
+
from tree_sitter import Language
|
|
17
|
+
|
|
18
|
+
from .gui_colors import GuiColors
|
|
19
|
+
from .syntax_specs import SyntaxSpecs
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OptionsDict(TypedDict, total=False):
|
|
23
|
+
"""Every option the editor and the built-in behaviors understand
|
|
24
|
+
|
|
25
|
+
Behaviors from outside the package add their options with `register_option`
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
# Indentation
|
|
29
|
+
space_indent_width: int
|
|
30
|
+
tab_indent_width: int
|
|
31
|
+
indent_using_tabs: bool
|
|
32
|
+
copy_indents_as_spaces: bool
|
|
33
|
+
# Opening and closing characters, like ["()", "[]"]
|
|
34
|
+
indent_bracket_pairs: list[str]
|
|
35
|
+
# Language and appearance. The highlight query comes from the language
|
|
36
|
+
language: Language
|
|
37
|
+
syntax: SyntaxSpecs
|
|
38
|
+
colors: GuiColors
|
|
39
|
+
font: QtGui.QFont
|
|
40
|
+
# Tab completion
|
|
41
|
+
vim_completion_keys: bool # Ctrl+N/Ctrl+P for next/prev and Ctrl+Y to accept
|
|
42
|
+
debounce_delay: int # milliseconds
|
|
43
|
+
# Brackets, as pairs of opening and closing characters
|
|
44
|
+
auto_bracket_pairs: list[str]
|
|
45
|
+
highlight_bracket_pairs: list[str]
|
|
46
|
+
# The characters that open and close strings, like "'\""
|
|
47
|
+
highlight_quote_chars: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# The value of every option that isn't given. The language and font have none
|
|
51
|
+
OPTION_DEFAULTS: OptionsDict = {
|
|
52
|
+
"space_indent_width": 4,
|
|
53
|
+
"tab_indent_width": 8,
|
|
54
|
+
"indent_using_tabs": False,
|
|
55
|
+
"copy_indents_as_spaces": True,
|
|
56
|
+
"indent_bracket_pairs": ["()", "[]", "{}"],
|
|
57
|
+
"syntax": {},
|
|
58
|
+
"colors": {},
|
|
59
|
+
"vim_completion_keys": True,
|
|
60
|
+
"debounce_delay": 150,
|
|
61
|
+
"auto_bracket_pairs": ["()", "[]", "{}", '""', "''", "``"],
|
|
62
|
+
"highlight_bracket_pairs": ["()", "[]", "{}"],
|
|
63
|
+
"highlight_quote_chars": "\"'`",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# The keys of the built-in options
|
|
67
|
+
OPTION_KEYS = frozenset(get_type_hints(OptionsDict))
|
|
68
|
+
|
|
69
|
+
# What EditorOptions._lookup returns for an option without a value
|
|
70
|
+
_MISSING = object()
|
|
71
|
+
|
|
72
|
+
# Option key -> the function that applies its value
|
|
73
|
+
OptionHandlers = Mapping[str, Callable[[Any], object]]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _copy_mutable(value: Any) -> Any:
|
|
77
|
+
"""A deep copy of a list or dict value, so the store's own can't be edited in place
|
|
78
|
+
|
|
79
|
+
Other values are returned as they are, since languages and fonts can't be deep-copied
|
|
80
|
+
"""
|
|
81
|
+
return copy.deepcopy(value) if isinstance(value, (list, dict)) else value
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class OptionSchema:
|
|
85
|
+
"""The type every option's values must have, and the registered options' defaults
|
|
86
|
+
|
|
87
|
+
The built-in options come from OptionsDict, and behaviors from outside the
|
|
88
|
+
package add theirs with `register`
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(self) -> None:
|
|
92
|
+
self._types: dict[str, type] = {
|
|
93
|
+
key: get_origin(hint) or hint
|
|
94
|
+
for key, hint in get_type_hints(OptionsDict).items()
|
|
95
|
+
}
|
|
96
|
+
self._defaults: dict[str, Any] = {}
|
|
97
|
+
|
|
98
|
+
def register(self, key: str, value_type: type, default: object = None) -> None:
|
|
99
|
+
"""Add an option, for a behavior from outside the package
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
key: The option's name, which mustn't be a built-in option
|
|
103
|
+
value_type: The type its values must have
|
|
104
|
+
default: Its value when it isn't set. None means it has no default,
|
|
105
|
+
and handlers for it wait until it is set
|
|
106
|
+
"""
|
|
107
|
+
if key in OPTION_KEYS:
|
|
108
|
+
raise ValueError(f"{key!r} is a built-in editor option")
|
|
109
|
+
# Check the default first, so a bad one leaves the schema as it was
|
|
110
|
+
if default is not None and not isinstance(default, value_type):
|
|
111
|
+
raise TypeError(
|
|
112
|
+
f"Editor option {key!r} must be a {value_type.__name__}, "
|
|
113
|
+
f"not {type(default).__name__}"
|
|
114
|
+
)
|
|
115
|
+
self._types[key] = value_type
|
|
116
|
+
self._defaults.pop(key, None)
|
|
117
|
+
if default is not None:
|
|
118
|
+
self._defaults[key] = _copy_mutable(default)
|
|
119
|
+
|
|
120
|
+
def unregister(self, key: str) -> None:
|
|
121
|
+
"""Remove an option added with `register`"""
|
|
122
|
+
if key in OPTION_KEYS:
|
|
123
|
+
raise ValueError(f"{key!r} is a built-in editor option")
|
|
124
|
+
self._types.pop(key, None)
|
|
125
|
+
self._defaults.pop(key, None)
|
|
126
|
+
|
|
127
|
+
def registered_default(self, key: str) -> Any:
|
|
128
|
+
"""A registered option's stored default, not a copy. Raises a KeyError if it
|
|
129
|
+
has none
|
|
130
|
+
"""
|
|
131
|
+
return self._defaults[key]
|
|
132
|
+
|
|
133
|
+
def check_keys(self, keys: Iterable[str]) -> None:
|
|
134
|
+
"""Raise a KeyError for any key that isn't a known option"""
|
|
135
|
+
unknown = set(keys) - set(self._types)
|
|
136
|
+
if unknown:
|
|
137
|
+
raise KeyError(f"Unknown editor options: {sorted(unknown)}")
|
|
138
|
+
|
|
139
|
+
def check(self, opts: Mapping[str, object]) -> None:
|
|
140
|
+
"""Raise a KeyError for unknown keys, or a TypeError for wrong value types"""
|
|
141
|
+
self.check_keys(opts)
|
|
142
|
+
for key, value in opts.items():
|
|
143
|
+
expected = self._types[key]
|
|
144
|
+
if not isinstance(value, expected):
|
|
145
|
+
raise TypeError(
|
|
146
|
+
f"Editor option {key!r} must be a {expected.__name__}, "
|
|
147
|
+
f"not {type(value).__name__}"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# There is one schema for the process, so a registered option works in every editor
|
|
152
|
+
OPTION_SCHEMA = OptionSchema()
|
|
153
|
+
register_option = OPTION_SCHEMA.register
|
|
154
|
+
unregister_option = OPTION_SCHEMA.unregister
|
|
155
|
+
check_option_keys = OPTION_SCHEMA.check_keys
|
|
156
|
+
check_options = OPTION_SCHEMA.check
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class EditorOptions(QtCore.QObject):
|
|
160
|
+
"""The options shared by an editor and its behaviors
|
|
161
|
+
|
|
162
|
+
Options that aren't given take their value from OPTION_DEFAULTS, or from
|
|
163
|
+
the default they were registered with. Setting options emits
|
|
164
|
+
`optionsUpdated` with the keys whose value changed
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
optionsUpdated = QtCore.Signal(list) # list of str
|
|
168
|
+
|
|
169
|
+
def __init__(self, opts: OptionsDict | None = None) -> None:
|
|
170
|
+
super().__init__()
|
|
171
|
+
opts = {} if opts is None else opts
|
|
172
|
+
check_options(opts)
|
|
173
|
+
self._options: dict[str, Any] = {
|
|
174
|
+
**copy.deepcopy(OPTION_DEFAULTS),
|
|
175
|
+
**{key: _copy_mutable(value) for key, value in opts.items()},
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
def _lookup(self, key: str) -> Any:
|
|
179
|
+
"""The option's value, or its registered default, or _MISSING
|
|
180
|
+
|
|
181
|
+
A registered default is looked up every time, so registering the
|
|
182
|
+
option again later changes it for editors that already read it. This is
|
|
183
|
+
the stored value, not a copy, so callers must not change it
|
|
184
|
+
"""
|
|
185
|
+
if key in self._options:
|
|
186
|
+
return self._options[key]
|
|
187
|
+
try:
|
|
188
|
+
return OPTION_SCHEMA.registered_default(key)
|
|
189
|
+
except KeyError:
|
|
190
|
+
return _MISSING
|
|
191
|
+
|
|
192
|
+
if TYPE_CHECKING:
|
|
193
|
+
# Reading a built-in option gives the type OptionsDict declares for it
|
|
194
|
+
# fmt: off
|
|
195
|
+
@overload
|
|
196
|
+
def __getitem__(self, key: Literal["space_indent_width", "tab_indent_width", "debounce_delay"]) -> int: ...
|
|
197
|
+
@overload
|
|
198
|
+
def __getitem__(self, key: Literal["indent_using_tabs", "copy_indents_as_spaces", "vim_completion_keys"]) -> bool: ...
|
|
199
|
+
@overload
|
|
200
|
+
def __getitem__(self, key: Literal["indent_bracket_pairs", "auto_bracket_pairs", "highlight_bracket_pairs"]) -> list[str]: ...
|
|
201
|
+
@overload
|
|
202
|
+
def __getitem__(self, key: Literal["highlight_quote_chars"]) -> str: ...
|
|
203
|
+
@overload
|
|
204
|
+
def __getitem__(self, key: Literal["language"]) -> Language: ...
|
|
205
|
+
@overload
|
|
206
|
+
def __getitem__(self, key: Literal["syntax"]) -> SyntaxSpecs: ...
|
|
207
|
+
@overload
|
|
208
|
+
def __getitem__(self, key: Literal["colors"]) -> GuiColors: ...
|
|
209
|
+
@overload
|
|
210
|
+
def __getitem__(self, key: Literal["font"]) -> QtGui.QFont: ...
|
|
211
|
+
@overload
|
|
212
|
+
def __getitem__(self, key: str) -> Any: ...
|
|
213
|
+
# fmt: on
|
|
214
|
+
|
|
215
|
+
def __getitem__(self, key: str) -> Any:
|
|
216
|
+
value = self._lookup(key)
|
|
217
|
+
if value is _MISSING:
|
|
218
|
+
raise KeyError(key)
|
|
219
|
+
# A list or dict is a copy, so changing it means setting it again
|
|
220
|
+
return _copy_mutable(value)
|
|
221
|
+
|
|
222
|
+
def __setitem__(self, key: str, value: Any) -> None:
|
|
223
|
+
self.update({key: value})
|
|
224
|
+
|
|
225
|
+
def __contains__(self, key: str) -> bool:
|
|
226
|
+
return self._lookup(key) is not _MISSING
|
|
227
|
+
|
|
228
|
+
def update(self, opts: Mapping[str, Any]) -> None:
|
|
229
|
+
"""Set several options, emitting `optionsUpdated` once for the changed ones"""
|
|
230
|
+
check_options(opts)
|
|
231
|
+
changed = [key for key, value in opts.items() if self._lookup(key) != value]
|
|
232
|
+
self._options.update({key: _copy_mutable(value) for key, value in opts.items()})
|
|
233
|
+
if changed:
|
|
234
|
+
self.optionsUpdated.emit(changed)
|
|
235
|
+
|
|
236
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
237
|
+
value = self._lookup(key)
|
|
238
|
+
return default if value is _MISSING else _copy_mutable(value)
|
|
239
|
+
|
|
240
|
+
def apply(self, handlers: OptionHandlers, keys: Iterable[str]) -> None:
|
|
241
|
+
"""Call the handler of each key that is set, with its value"""
|
|
242
|
+
for key in keys:
|
|
243
|
+
handler = handlers.get(key)
|
|
244
|
+
value = self._lookup(key)
|
|
245
|
+
if handler is not None and value is not _MISSING:
|
|
246
|
+
handler(_copy_mutable(value))
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""The "colors" option: named colors for the editor chrome and the behaviors
|
|
2
|
+
|
|
3
|
+
Every color the editor and the built-in behaviors use has a default in
|
|
4
|
+
GUI_COLOR_DEFAULTS, or in DERIVED_GUI_COLORS when it depends on another color.
|
|
5
|
+
`gui_color` falls back to them for any color a theme doesn't set.
|
|
6
|
+
|
|
7
|
+
Hex colors with alpha should be given in ARGB order, which is how QColor reads them.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
|
|
14
|
+
from Qt import QtGui
|
|
15
|
+
|
|
16
|
+
# Gui color name -> hex color
|
|
17
|
+
GuiColors = dict[str, str]
|
|
18
|
+
|
|
19
|
+
GUI_COLOR_DEFAULTS: GuiColors = {
|
|
20
|
+
"bg": "#282828",
|
|
21
|
+
"fg": "#DCDCDC",
|
|
22
|
+
"gutter": "#282828",
|
|
23
|
+
"gutter_fg": "#969696",
|
|
24
|
+
"bg_dim": "#503C3C3C",
|
|
25
|
+
"fg_dim": "#C8787878",
|
|
26
|
+
"hl_dim": "#96464646",
|
|
27
|
+
"manual_fold": "#D7BA7D",
|
|
28
|
+
"match_hl": "#50FFFF00",
|
|
29
|
+
"menu": "#2B2B2B",
|
|
30
|
+
"pair_hl": "#C8C8C8",
|
|
31
|
+
"primary_cursor": "#FFFFFFFF",
|
|
32
|
+
"secondary_cursor": "#B4B4B4",
|
|
33
|
+
"selection": "#094771",
|
|
34
|
+
"secondary_selection": "#B4B4B4",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Gui color name -> its default, made from the other colors
|
|
38
|
+
DERIVED_GUI_COLORS: dict[str, Callable[[GuiColors], QtGui.QColor]] = {
|
|
39
|
+
# The space below the document is a shade darker than the background
|
|
40
|
+
"outside_bg": lambda colors: gui_color(colors, "bg").darker(120),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def gui_color(colors: GuiColors, name: str) -> QtGui.QColor:
|
|
45
|
+
"""Get a gui color, falling back to its default if the theme doesn't set it"""
|
|
46
|
+
if name in colors:
|
|
47
|
+
return QtGui.QColor(colors[name])
|
|
48
|
+
derive = DERIVED_GUI_COLORS.get(name)
|
|
49
|
+
if derive is not None:
|
|
50
|
+
return derive(colors)
|
|
51
|
+
return QtGui.QColor(GUI_COLOR_DEFAULTS[name])
|
QCodeSitter/gutters.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""The columns beside the editor viewport, like line numbers and fold markers"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from Qt import QtCore, QtGui, QtWidgets
|
|
9
|
+
|
|
10
|
+
from .gui_colors import GuiColors, gui_color
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .code_editor import CodeEditor
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GutterLayout:
|
|
17
|
+
"""Lays out an editor's gutters left of its viewport, in the order they were added
|
|
18
|
+
|
|
19
|
+
The editor owns one as `editor.gutters`, and iterating it gives the gutters
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, editor: CodeEditor) -> None:
|
|
23
|
+
self.editor = editor
|
|
24
|
+
self._gutters: list[GutterWidget] = []
|
|
25
|
+
|
|
26
|
+
def __iter__(self) -> Iterator[GutterWidget]:
|
|
27
|
+
return iter(list(self._gutters))
|
|
28
|
+
|
|
29
|
+
def add(self, gutter: GutterWidget) -> None:
|
|
30
|
+
"""Add a gutter to the right of the others. GutterWidget calls this"""
|
|
31
|
+
gutter.reserved_width = gutter.width_hint()
|
|
32
|
+
self._gutters.append(gutter)
|
|
33
|
+
self.layout()
|
|
34
|
+
|
|
35
|
+
def remove(self, gutter: GutterWidget) -> None:
|
|
36
|
+
"""Remove a gutter and close the gap. GutterWidget.remove calls this"""
|
|
37
|
+
self._gutters.remove(gutter)
|
|
38
|
+
self.layout()
|
|
39
|
+
|
|
40
|
+
def layout(self) -> None:
|
|
41
|
+
"""Make room for the gutters left of the viewport, and put them there"""
|
|
42
|
+
margins = self.editor.viewportMargins()
|
|
43
|
+
margins.setLeft(sum(g.reserved_width for g in self._gutters))
|
|
44
|
+
self.editor.setViewportMargins(margins)
|
|
45
|
+
self.place()
|
|
46
|
+
|
|
47
|
+
def place(self) -> None:
|
|
48
|
+
"""Fit the gutters to the editor's height, side by side"""
|
|
49
|
+
rect = self.editor.contentsRect()
|
|
50
|
+
left = rect.left()
|
|
51
|
+
for gutter in self._gutters:
|
|
52
|
+
gutter.setGeometry(left, rect.top(), gutter.reserved_width, rect.height())
|
|
53
|
+
left += gutter.reserved_width
|
|
54
|
+
|
|
55
|
+
def update(self) -> None:
|
|
56
|
+
"""Repaint every gutter, like after lines are hidden or shown"""
|
|
57
|
+
for gutter in self._gutters:
|
|
58
|
+
gutter.update()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class GutterWidget(QtWidgets.QWidget):
|
|
62
|
+
"""Base class for a column beside the editor viewport, like line numbers
|
|
63
|
+
|
|
64
|
+
A gutter adds itself to the editor's GutterLayout when it is made.
|
|
65
|
+
Subclasses define `width_hint` and `paintEvent`, and call `remove` to take
|
|
66
|
+
the gutter away.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, editor: CodeEditor) -> None:
|
|
70
|
+
super().__init__(editor)
|
|
71
|
+
self.editor: CodeEditor = editor
|
|
72
|
+
self.fg_color = QtGui.QColor()
|
|
73
|
+
self.bg_color = QtGui.QColor()
|
|
74
|
+
# The width the editor has made room for
|
|
75
|
+
self.reserved_width = 0
|
|
76
|
+
self.editor.blockCountChanged.connect(self.update_width)
|
|
77
|
+
self.editor.updateRequest.connect(self.update_gutter)
|
|
78
|
+
self.editor.gutters.add(self)
|
|
79
|
+
if self.editor.isVisible():
|
|
80
|
+
self.show()
|
|
81
|
+
|
|
82
|
+
def set_gutter_colors(self, colors: GuiColors) -> None:
|
|
83
|
+
"""Paint with the "gutter_fg" and "gutter" gui colors"""
|
|
84
|
+
self.fg_color = gui_color(colors, "gutter_fg")
|
|
85
|
+
self.bg_color = gui_color(colors, "gutter")
|
|
86
|
+
self.update()
|
|
87
|
+
|
|
88
|
+
def sizeHint(self) -> QtCore.QSize:
|
|
89
|
+
return QtCore.QSize(self.width_hint(), 0)
|
|
90
|
+
|
|
91
|
+
def width_hint(self) -> int:
|
|
92
|
+
raise NotImplementedError("width_hint must be defined")
|
|
93
|
+
|
|
94
|
+
def update_width(self) -> None:
|
|
95
|
+
# This gets called on every full-viewport update, so skip it if nothing changed
|
|
96
|
+
width = self.width_hint()
|
|
97
|
+
if self.reserved_width != width:
|
|
98
|
+
self.reserved_width = width
|
|
99
|
+
self.editor.gutters.layout()
|
|
100
|
+
|
|
101
|
+
def remove(self) -> None:
|
|
102
|
+
"""Take the gutter out of the editor, and give its space back"""
|
|
103
|
+
self.editor.blockCountChanged.disconnect(self.update_width)
|
|
104
|
+
self.editor.updateRequest.disconnect(self.update_gutter)
|
|
105
|
+
self.editor.gutters.remove(self)
|
|
106
|
+
self.deleteLater()
|
|
107
|
+
|
|
108
|
+
@QtCore.Slot(QtCore.QRect, int)
|
|
109
|
+
def update_gutter(self, rect: QtCore.QRect, dy: int) -> None:
|
|
110
|
+
if dy:
|
|
111
|
+
self.scroll(0, dy)
|
|
112
|
+
else:
|
|
113
|
+
# Repaint the rows of the gutter that changed in the viewport
|
|
114
|
+
self.update(0, rect.y(), self.width(), rect.height())
|
|
115
|
+
|
|
116
|
+
if rect.contains(self.editor.viewport().rect()):
|
|
117
|
+
self.update_width()
|
|
118
|
+
|
|
119
|
+
def visible_blocks(
|
|
120
|
+
self, bottom_limit: float | None = None
|
|
121
|
+
) -> Iterator[tuple[QtGui.QTextBlock, float, float]]:
|
|
122
|
+
"""Yield each visible block with its top and bottom in gutter coordinates
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
bottom_limit: Stop after the block that reaches past this y position
|
|
126
|
+
"""
|
|
127
|
+
block = self.editor.firstVisibleBlock()
|
|
128
|
+
top = (
|
|
129
|
+
self.editor.blockBoundingGeometry(block)
|
|
130
|
+
.translated(self.editor.contentOffset())
|
|
131
|
+
.top()
|
|
132
|
+
)
|
|
133
|
+
while block.isValid() and (bottom_limit is None or top <= bottom_limit):
|
|
134
|
+
bottom = top + self.editor.blockBoundingRect(block).height()
|
|
135
|
+
if block.isVisible():
|
|
136
|
+
yield block, top, bottom
|
|
137
|
+
block = block.next()
|
|
138
|
+
top = bottom
|
|
139
|
+
|
|
140
|
+
def paintEvent(self, event: QtGui.QPaintEvent) -> None:
|
|
141
|
+
raise NotImplementedError("paintEvent must be defined")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Plugins that put the editor into other applications"""
|