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.
Files changed (49) hide show
  1. QCodeSitter/__init__.py +38 -0
  2. QCodeSitter/behaviors/__init__.py +115 -0
  3. QCodeSitter/behaviors/auto_bracket.py +141 -0
  4. QCodeSitter/behaviors/code_folding.py +530 -0
  5. QCodeSitter/behaviors/comment_toggle.py +71 -0
  6. QCodeSitter/behaviors/highlight_matching_brackets.py +181 -0
  7. QCodeSitter/behaviors/highlight_matching_selection.py +59 -0
  8. QCodeSitter/behaviors/line_numbers.py +49 -0
  9. QCodeSitter/behaviors/multi_cursor_paint.py +131 -0
  10. QCodeSitter/behaviors/overscroll.py +104 -0
  11. QCodeSitter/behaviors/smart_indent.py +191 -0
  12. QCodeSitter/behaviors/syntax_highlighting.py +181 -0
  13. QCodeSitter/behaviors/tab_completion/__init__.py +257 -0
  14. QCodeSitter/behaviors/tab_completion/popup.py +123 -0
  15. QCodeSitter/behaviors/tab_completion/providers.py +66 -0
  16. QCodeSitter/behaviors/tab_completion/types.py +55 -0
  17. QCodeSitter/code_editor.py +540 -0
  18. QCodeSitter/cursors/__init__.py +8 -0
  19. QCodeSitter/cursors/clipboard.py +51 -0
  20. QCodeSitter/cursors/iterator.py +141 -0
  21. QCodeSitter/cursors/line_edits.py +128 -0
  22. QCodeSitter/cursors/multi.py +240 -0
  23. QCodeSitter/cursors/state.py +55 -0
  24. QCodeSitter/cursors/tracked.py +97 -0
  25. QCodeSitter/defaults.py +93 -0
  26. QCodeSitter/editing_keys.py +150 -0
  27. QCodeSitter/editor_options.py +246 -0
  28. QCodeSitter/gui_colors.py +51 -0
  29. QCodeSitter/gutters.py +141 -0
  30. QCodeSitter/integrations/__init__.py +1 -0
  31. QCodeSitter/integrations/preditor_interface.py +390 -0
  32. QCodeSitter/languages/__init__.py +99 -0
  33. QCodeSitter/languages/base.py +60 -0
  34. QCodeSitter/languages/python.py +146 -0
  35. QCodeSitter/languages/python_highlights.py +444 -0
  36. QCodeSitter/py.typed +0 -0
  37. QCodeSitter/queries/__init__.py +73 -0
  38. QCodeSitter/queries/predicates.py +172 -0
  39. QCodeSitter/syntax_specs.py +65 -0
  40. QCodeSitter/themes.py +271 -0
  41. QCodeSitter/tracked_document.py +107 -0
  42. QCodeSitter/tree_manager.py +180 -0
  43. QCodeSitter/utils.py +84 -0
  44. qcodesitter-0.1.0.dist-info/METADATA +255 -0
  45. qcodesitter-0.1.0.dist-info/RECORD +49 -0
  46. qcodesitter-0.1.0.dist-info/WHEEL +5 -0
  47. qcodesitter-0.1.0.dist-info/entry_points.txt +2 -0
  48. qcodesitter-0.1.0.dist-info/licenses/LICENSE.txt +7 -0
  49. qcodesitter-0.1.0.dist-info/top_level.txt +1 -0
@@ -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"""