pyqt-code-editor 0.0.1__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.
- pyqt_code_editor/__init__.py +5 -0
- pyqt_code_editor/_settings.py +232 -0
- pyqt_code_editor/app.py +438 -0
- pyqt_code_editor/code_editors/__init__.py +1 -0
- pyqt_code_editor/code_editors/code_editors.py +33 -0
- pyqt_code_editor/code_editors/languages/__init__.py +0 -0
- pyqt_code_editor/code_editors/languages/generic.py +11 -0
- pyqt_code_editor/code_editors/languages/markdown.py +11 -0
- pyqt_code_editor/code_editors/languages/python.py +13 -0
- pyqt_code_editor/code_editors/languages/text.py +9 -0
- pyqt_code_editor/components/__init__.py +0 -0
- pyqt_code_editor/components/editor_panel.py +399 -0
- pyqt_code_editor/components/find_in_files.py +199 -0
- pyqt_code_editor/components/jupyter_console.py +487 -0
- pyqt_code_editor/components/project_explorer.py +515 -0
- pyqt_code_editor/components/settings_panel.py +162 -0
- pyqt_code_editor/components/sigmund.py +75 -0
- pyqt_code_editor/components/workspace_explorer.py +162 -0
- pyqt_code_editor/mixins/__init__.py +15 -0
- pyqt_code_editor/mixins/auto_pair.py +201 -0
- pyqt_code_editor/mixins/base.py +142 -0
- pyqt_code_editor/mixins/check.py +94 -0
- pyqt_code_editor/mixins/comment.py +91 -0
- pyqt_code_editor/mixins/complete.py +482 -0
- pyqt_code_editor/mixins/execute.py +182 -0
- pyqt_code_editor/mixins/file_link.py +166 -0
- pyqt_code_editor/mixins/line_number.py +206 -0
- pyqt_code_editor/mixins/python_auto_indent.py +239 -0
- pyqt_code_editor/mixins/search_replace.py +396 -0
- pyqt_code_editor/mixins/shortcuts.py +228 -0
- pyqt_code_editor/mixins/smart_backspace_delete.py +120 -0
- pyqt_code_editor/mixins/symbols.py +45 -0
- pyqt_code_editor/mixins/theme.py +99 -0
- pyqt_code_editor/mixins/widgets/__init__.py +0 -0
- pyqt_code_editor/mixins/zoom.py +51 -0
- pyqt_code_editor/python_utils.py +936 -0
- pyqt_code_editor/signal_router.py +116 -0
- pyqt_code_editor/syntax_highlighters/__init__.py +0 -0
- pyqt_code_editor/syntax_highlighters/languages/__init__.py +0 -0
- pyqt_code_editor/syntax_highlighters/languages/generic.py +86 -0
- pyqt_code_editor/syntax_highlighters/languages/python.py +62 -0
- pyqt_code_editor/syntax_highlighters/syntax_highlighter.py +26 -0
- pyqt_code_editor/themes.py +36 -0
- pyqt_code_editor/utils.py +96 -0
- pyqt_code_editor/watchdog.py +141 -0
- pyqt_code_editor/widgets/__init__.py +6 -0
- pyqt_code_editor/widgets/calltip_widget.py +44 -0
- pyqt_code_editor/widgets/completion_popup.py +89 -0
- pyqt_code_editor/widgets/dock.py +15 -0
- pyqt_code_editor/widgets/quick_open_dialog.py +196 -0
- pyqt_code_editor/widgets/quick_open_file_dialog.py +32 -0
- pyqt_code_editor/widgets/quick_symbol_dialog.py +13 -0
- pyqt_code_editor/widgets/search_replace_frame.py +128 -0
- pyqt_code_editor/widgets/tab_splitter.py +22 -0
- pyqt_code_editor/widgets/tabbed_editor.py +351 -0
- pyqt_code_editor/worker/__init__.py +0 -0
- pyqt_code_editor/worker/languages/__init__.py +0 -0
- pyqt_code_editor/worker/languages/generic.py +15 -0
- pyqt_code_editor/worker/languages/python.py +17 -0
- pyqt_code_editor/worker/manager.py +113 -0
- pyqt_code_editor/worker/process.py +128 -0
- pyqt_code_editor/worker/providers/__init__.py +1 -0
- pyqt_code_editor/worker/providers/codestral.py +56 -0
- pyqt_code_editor/worker/providers/jedi.py +115 -0
- pyqt_code_editor/worker/providers/ruff.py +45 -0
- pyqt_code_editor/worker/providers/sigmund.py +29 -0
- pyqt_code_editor/worker/providers/symbol.py +78 -0
- pyqt_code_editor/worker/settings.py +1 -0
- pyqt_code_editor-0.0.1.dist-info/METADATA +69 -0
- pyqt_code_editor-0.0.1.dist-info/RECORD +73 -0
- pyqt_code_editor-0.0.1.dist-info/WHEEL +4 -0
- pyqt_code_editor-0.0.1.dist-info/entry_points.txt +4 -0
- pyqt_code_editor-0.0.1.dist-info/licenses/COPYING +674 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
from qtpy.QtCore import Signal, Qt, QTimer
|
|
2
|
+
from qtpy.QtGui import QTextCursor, QTextCharFormat, QColor, QKeySequence
|
|
3
|
+
from qtpy.QtWidgets import QShortcut, QTextEdit
|
|
4
|
+
from .. python_utils import extract_cells_from_code
|
|
5
|
+
from .. import settings
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Execute:
|
|
12
|
+
"""A mixin for QPlainTextEdit that signals that code should be executed.
|
|
13
|
+
|
|
14
|
+
# Code execution
|
|
15
|
+
|
|
16
|
+
- The shortcut for executing code is defined as
|
|
17
|
+
settings.shortcut_execute_code.
|
|
18
|
+
- If text is selected, the selection is expanded to to full lines. This
|
|
19
|
+
ensures clean execution of partial line selections.
|
|
20
|
+
- When nothing is selected, it identifies the "cell" containing the cursor.
|
|
21
|
+
This is done by extract_cells_from_code, which returns a list of cells,
|
|
22
|
+
where each cell is a dict as below. The cell that contains the cursor
|
|
23
|
+
position is selected.
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
{
|
|
27
|
+
'description': str | None,
|
|
28
|
+
'start_pos': int,
|
|
29
|
+
'end_pos': int,
|
|
30
|
+
'code': str
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
- Next, the selected text is executed by emitted execute_code.
|
|
34
|
+
|
|
35
|
+
# File execution
|
|
36
|
+
|
|
37
|
+
- The shortcut for executing a full file is defined as
|
|
38
|
+
settings.shortcut_execute_file.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
execute_code = Signal(str)
|
|
42
|
+
execute_file = Signal(str)
|
|
43
|
+
|
|
44
|
+
def __init__(self, *args, **kwargs):
|
|
45
|
+
"""Setup keyboard shortcuts for code execution."""
|
|
46
|
+
# Shortcut for executing current cell or selection
|
|
47
|
+
super().__init__(*args, **kwargs)
|
|
48
|
+
logger.info("Initializing Execute")
|
|
49
|
+
self.execute_code_shortcut = QShortcut(
|
|
50
|
+
QKeySequence(settings.shortcut_execute_code),
|
|
51
|
+
self,
|
|
52
|
+
context=Qt.WidgetWithChildrenShortcut
|
|
53
|
+
)
|
|
54
|
+
self.execute_code_shortcut.activated.connect(self.execute_selected_text)
|
|
55
|
+
|
|
56
|
+
# Shortcut for executing the entire file
|
|
57
|
+
self.execute_file_shortcut = QShortcut(
|
|
58
|
+
QKeySequence(settings.shortcut_execute_file),
|
|
59
|
+
self,
|
|
60
|
+
context=Qt.WidgetWithChildrenShortcut
|
|
61
|
+
)
|
|
62
|
+
self.execute_file_shortcut.activated.connect(self.execute_current_file)
|
|
63
|
+
logger.info("Execute shortcuts initialized")
|
|
64
|
+
|
|
65
|
+
def execute_selected_text(self):
|
|
66
|
+
"""Execute the selected text or the current cell if no text is selected."""
|
|
67
|
+
cursor = self.textCursor()
|
|
68
|
+
if cursor.hasSelection():
|
|
69
|
+
# Get the start and end positions
|
|
70
|
+
start_pos = cursor.selectionStart()
|
|
71
|
+
end_pos = cursor.selectionEnd()
|
|
72
|
+
|
|
73
|
+
# Create a new cursor to find the start of the first line
|
|
74
|
+
start_cursor = QTextCursor(self.document())
|
|
75
|
+
start_cursor.setPosition(start_pos)
|
|
76
|
+
start_cursor.movePosition(QTextCursor.StartOfLine)
|
|
77
|
+
|
|
78
|
+
# Create a new cursor to find the end of the last line
|
|
79
|
+
end_cursor = QTextCursor(self.document())
|
|
80
|
+
end_cursor.setPosition(end_pos)
|
|
81
|
+
if not end_cursor.atBlockEnd():
|
|
82
|
+
end_cursor.movePosition(QTextCursor.EndOfLine)
|
|
83
|
+
|
|
84
|
+
# Set the expanded selection
|
|
85
|
+
cursor.setPosition(start_cursor.position())
|
|
86
|
+
cursor.setPosition(end_cursor.position(), QTextCursor.KeepAnchor)
|
|
87
|
+
self.setTextCursor(cursor)
|
|
88
|
+
|
|
89
|
+
# Get the selected text and execute it
|
|
90
|
+
selected_text = cursor.selectedText().replace('\u2029', '\n') # Handle QTextEdit's line separators
|
|
91
|
+
if selected_text:
|
|
92
|
+
logger.info("Executing selected text (%d characters)", len(selected_text))
|
|
93
|
+
self.highlight_executed_code(cursor)
|
|
94
|
+
self.execute_code.emit(selected_text)
|
|
95
|
+
else:
|
|
96
|
+
# No selection, execute the current cell
|
|
97
|
+
logger.info("No selection found, executing current cell")
|
|
98
|
+
self.execute_current_cell()
|
|
99
|
+
|
|
100
|
+
def execute_current_cell(self):
|
|
101
|
+
"""Execute the cell that contains the cursor."""
|
|
102
|
+
cell = self.get_cell_at_cursor()
|
|
103
|
+
if cell:
|
|
104
|
+
# Create a cursor for the cell range
|
|
105
|
+
cursor = QTextCursor(self.document())
|
|
106
|
+
cursor.setPosition(cell['start_pos'])
|
|
107
|
+
cursor.setPosition(cell['end_pos'], QTextCursor.KeepAnchor)
|
|
108
|
+
|
|
109
|
+
# Highlight the cell and execute it
|
|
110
|
+
logger.info("Executing cell %s-%s (%d characters)",
|
|
111
|
+
cell['start_pos'], cell['end_pos'], len(cell['code']))
|
|
112
|
+
if cell['description']:
|
|
113
|
+
logger.info("Cell description: %s", cell['description'])
|
|
114
|
+
|
|
115
|
+
self.highlight_executed_code(cursor)
|
|
116
|
+
self.execute_code.emit(cell['code'])
|
|
117
|
+
else:
|
|
118
|
+
logger.info("No cell found at cursor position")
|
|
119
|
+
|
|
120
|
+
def execute_current_file(self):
|
|
121
|
+
"""Execute the entire file."""
|
|
122
|
+
text = self.toPlainText()
|
|
123
|
+
if not hasattr(self, 'code_editor_file_path'):
|
|
124
|
+
return
|
|
125
|
+
if self.code_editor_file_path is None:
|
|
126
|
+
return
|
|
127
|
+
logger.info("Executing entire file (%d characters)", len(text))
|
|
128
|
+
self.execute_file.emit(self.code_editor_file_path)
|
|
129
|
+
|
|
130
|
+
def get_cell_at_cursor(self):
|
|
131
|
+
"""Get the cell that contains the current cursor position."""
|
|
132
|
+
cursor_pos = self.textCursor().position()
|
|
133
|
+
text = self.toPlainText()
|
|
134
|
+
|
|
135
|
+
logger.info("Finding cell at cursor position %d", cursor_pos)
|
|
136
|
+
cells = extract_cells_from_code(text)
|
|
137
|
+
logger.info("Found %d cells in document", len(cells))
|
|
138
|
+
|
|
139
|
+
# Find the cell that contains the cursor position
|
|
140
|
+
for cell in cells:
|
|
141
|
+
if cell['start_pos'] <= cursor_pos <= cell['end_pos']:
|
|
142
|
+
logger.info("Cursor is in cell %s-%s", cell['start_pos'], cell['end_pos'])
|
|
143
|
+
return cell
|
|
144
|
+
|
|
145
|
+
# If no cell is found (shouldn't happen as the entire document is treated as one cell)
|
|
146
|
+
# but just in case, return the entire document as a cell
|
|
147
|
+
if cells:
|
|
148
|
+
logger.info("No specific cell found, using first cell")
|
|
149
|
+
return cells[0]
|
|
150
|
+
logger.info("No cells found in document")
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
def highlight_executed_code(self, cursor, duration=200):
|
|
154
|
+
"""Temporarily highlight the executed code for visual feedback.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
cursor: QTextCursor with the selection to highlight
|
|
158
|
+
duration: How long to show the highlight in milliseconds
|
|
159
|
+
"""
|
|
160
|
+
# Store the current selection
|
|
161
|
+
original_cursor = self.textCursor()
|
|
162
|
+
|
|
163
|
+
# Apply the highlight cursor
|
|
164
|
+
self.setTextCursor(cursor)
|
|
165
|
+
|
|
166
|
+
# Create a highlighted format
|
|
167
|
+
highlight_format = QTextCharFormat()
|
|
168
|
+
highlight_format.setBackground(QColor(100, 100, 170, 50)) # Light blue background
|
|
169
|
+
|
|
170
|
+
# Apply the format
|
|
171
|
+
selection = QTextEdit.ExtraSelection()
|
|
172
|
+
selection.cursor = cursor
|
|
173
|
+
selection.format = highlight_format
|
|
174
|
+
self.setExtraSelections([selection])
|
|
175
|
+
logger.info("Highlighted executed code (duration: %dms)", duration)
|
|
176
|
+
|
|
177
|
+
# Restore the original cursor after a delay
|
|
178
|
+
def restore_selection():
|
|
179
|
+
self.setTextCursor(original_cursor)
|
|
180
|
+
self.setExtraSelections([])
|
|
181
|
+
|
|
182
|
+
QTimer.singleShot(duration, restore_selection)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import logging
|
|
3
|
+
import chardet
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from qtpy.QtCore import QFileSystemWatcher, Signal
|
|
6
|
+
from qtpy.QtWidgets import QMessageBox, QFileDialog
|
|
7
|
+
from .. import settings
|
|
8
|
+
logging.basicConfig(level=logging.INFO, force=True)
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FileLink:
|
|
13
|
+
"""
|
|
14
|
+
A mixin for QPlainTextEdit that links the content of the editor to a
|
|
15
|
+
file on disk. By default, the editor is not linked to any file.
|
|
16
|
+
|
|
17
|
+
A QFileSystemWatcher monitors the currently opened file. If the file is
|
|
18
|
+
changed on disk, the user is prompted to possibly reload. (See _on_file_changed.)
|
|
19
|
+
"""
|
|
20
|
+
file_saved = Signal(object, str)
|
|
21
|
+
file_name_changed = Signal(object, str, str)
|
|
22
|
+
code_editor_file_path = None # str or None
|
|
23
|
+
code_editor_encoding = None # str or None
|
|
24
|
+
_file_watcher = None # QFileSystemWatcher or None
|
|
25
|
+
|
|
26
|
+
def __init__(self, *args, **kwargs):
|
|
27
|
+
super().__init__(*args, **kwargs)
|
|
28
|
+
logger.info("Initializing FileLink")
|
|
29
|
+
self._watch_file_changes = True
|
|
30
|
+
|
|
31
|
+
def open_file(self, path: Path | str, encoding: str = None):
|
|
32
|
+
"""
|
|
33
|
+
Reads the content from a file and sets it as the editor content.
|
|
34
|
+
If the file does not exist, a sensible exception is raised.
|
|
35
|
+
If no encoding is specified, tries:
|
|
36
|
+
1) If it's pure ASCII, use 'utf-8'.
|
|
37
|
+
2) Else guess with chardet, or default to 'utf-8' if chardet yields None.
|
|
38
|
+
"""
|
|
39
|
+
path = Path(path) # ensure we have a Path object
|
|
40
|
+
if not path.is_file():
|
|
41
|
+
raise FileNotFoundError(f"No such file: {path}")
|
|
42
|
+
|
|
43
|
+
raw_data = path.read_bytes()
|
|
44
|
+
|
|
45
|
+
if encoding is None:
|
|
46
|
+
# First see if everything is within ASCII range
|
|
47
|
+
try:
|
|
48
|
+
raw_data.decode("utf-8")
|
|
49
|
+
# If no error -> it's strictly ASCII, so decode as UTF-8
|
|
50
|
+
used_encoding = "utf-8"
|
|
51
|
+
except UnicodeDecodeError:
|
|
52
|
+
# Not pure ASCII; let chardet pick
|
|
53
|
+
detect_result = chardet.detect(raw_data)
|
|
54
|
+
# If detection fails or returns None, default to utf-8
|
|
55
|
+
used_encoding = detect_result["encoding"] or "utf-8"
|
|
56
|
+
else:
|
|
57
|
+
used_encoding = encoding
|
|
58
|
+
logger.info(f'opening file as {used_encoding}')
|
|
59
|
+
# Try reading with the chosen encoding
|
|
60
|
+
with path.open("r", encoding=used_encoding) as f:
|
|
61
|
+
content = f.read()
|
|
62
|
+
|
|
63
|
+
# Store the content in the editor
|
|
64
|
+
self.setPlainText(content)
|
|
65
|
+
|
|
66
|
+
# Update internal state
|
|
67
|
+
self.code_editor_file_path = str(path)
|
|
68
|
+
self.code_editor_encoding = used_encoding
|
|
69
|
+
|
|
70
|
+
# (Re)watch this file
|
|
71
|
+
self._saving = False
|
|
72
|
+
self._watch_file(path)
|
|
73
|
+
self.set_modified(False)
|
|
74
|
+
|
|
75
|
+
def save_file(self):
|
|
76
|
+
"""
|
|
77
|
+
Saves the editor content to the file named code_editor_file_path,
|
|
78
|
+
using code_editor_encoding. If no valid path or encoding is available,
|
|
79
|
+
a sensible exception is raised.
|
|
80
|
+
"""
|
|
81
|
+
if not self.code_editor_file_path:
|
|
82
|
+
self.save_file_as()
|
|
83
|
+
return
|
|
84
|
+
if not self.code_editor_encoding:
|
|
85
|
+
self.code_editor_encoding = settings.default_encoding
|
|
86
|
+
path = Path(self.code_editor_file_path)
|
|
87
|
+
self._saving = True
|
|
88
|
+
try:
|
|
89
|
+
with path.open("w", encoding=self.code_editor_encoding) as f:
|
|
90
|
+
f.write(self.toPlainText())
|
|
91
|
+
except Exception as e:
|
|
92
|
+
QMessageBox.warning(self, "Error", f"Failed to save file:\n{str(e)}")
|
|
93
|
+
self.set_modified(False)
|
|
94
|
+
self.file_saved.emit(self, self.code_editor_encoding)
|
|
95
|
+
|
|
96
|
+
def save_file_as(self, path: Path | str = None):
|
|
97
|
+
"""
|
|
98
|
+
Saves the editor content to file and updates code_editor_file_path.
|
|
99
|
+
If no path is provided, the user asked to choose.
|
|
100
|
+
"""
|
|
101
|
+
if path is None:
|
|
102
|
+
if self.code_editor_file_path is None:
|
|
103
|
+
suggested_name = os.path.join(settings.current_folder,
|
|
104
|
+
settings.default_filename)
|
|
105
|
+
else:
|
|
106
|
+
suggested_name = self.code_editor_file_path
|
|
107
|
+
path, _ = QFileDialog.getSaveFileName(
|
|
108
|
+
self, "Save As", suggested_name, "All Files (*.*)")
|
|
109
|
+
if not path:
|
|
110
|
+
return
|
|
111
|
+
old_path = self.code_editor_encoding
|
|
112
|
+
settings.current_folder = os.path.dirname(path)
|
|
113
|
+
self.code_editor_file_path = str(path)
|
|
114
|
+
self.save_file()
|
|
115
|
+
# Update internal pointers
|
|
116
|
+
self._watch_file(path)
|
|
117
|
+
self.set_modified(False)
|
|
118
|
+
self.file_name_changed.emit(self, old_path, self.code_editor_file_path)
|
|
119
|
+
|
|
120
|
+
def _watch_file(self, path: Path):
|
|
121
|
+
"""Set up the QFileSystemWatcher to watch the newly opened or saved file."""
|
|
122
|
+
# If there's no watcher yet, create one.
|
|
123
|
+
if self._file_watcher is None:
|
|
124
|
+
self._file_watcher = QFileSystemWatcher()
|
|
125
|
+
self._file_watcher.fileChanged.connect(self._on_file_changed)
|
|
126
|
+
|
|
127
|
+
# Clear the watcher first (the old file path).
|
|
128
|
+
self._file_watcher.removePaths(self._file_watcher.files())
|
|
129
|
+
|
|
130
|
+
# Now watch the new file
|
|
131
|
+
self._file_watcher.addPath(str(path))
|
|
132
|
+
|
|
133
|
+
def _on_file_changed(self, changed_path: str):
|
|
134
|
+
"""
|
|
135
|
+
Called by QFileSystemWatcher whenever the watched file changes on disk.
|
|
136
|
+
By default, offers the user to reload. If reloaded, calls open_file again.
|
|
137
|
+
"""
|
|
138
|
+
if not self._watch_file_changes:
|
|
139
|
+
return
|
|
140
|
+
# If we have triggered a save ourselves, we ignore it but only once
|
|
141
|
+
if self._saving:
|
|
142
|
+
self._saving = False
|
|
143
|
+
return
|
|
144
|
+
# Only respond if it matches the current file
|
|
145
|
+
if changed_path != self.code_editor_file_path:
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
self._watch_file_changes = False
|
|
149
|
+
# Prompt user to reload
|
|
150
|
+
reply = QMessageBox.warning(
|
|
151
|
+
self,
|
|
152
|
+
"File changed on disk",
|
|
153
|
+
f"The file:\n{changed_path}\nhas changed on disk.\nReload?",
|
|
154
|
+
QMessageBox.Yes | QMessageBox.No,
|
|
155
|
+
QMessageBox.Yes
|
|
156
|
+
)
|
|
157
|
+
if reply == QMessageBox.Yes:
|
|
158
|
+
# Reload file
|
|
159
|
+
logger.info("Reloading file after external change.")
|
|
160
|
+
self.open_file(changed_path, encoding=self.code_editor_encoding)
|
|
161
|
+
else:
|
|
162
|
+
logger.info("User chose not to reload. Re-watching file anyway.")
|
|
163
|
+
# Re-add the file to watcher so we keep listening for future changes
|
|
164
|
+
if self._file_watcher is not None:
|
|
165
|
+
self._file_watcher.addPath(changed_path)
|
|
166
|
+
self._watch_file_changes = True
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
from qtpy.QtWidgets import QWidget, QToolTip
|
|
2
|
+
from qtpy.QtCore import Qt, QRect, QSize
|
|
3
|
+
from qtpy.QtGui import QPainter
|
|
4
|
+
from .. import settings
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LineNumberArea(QWidget):
|
|
8
|
+
"""
|
|
9
|
+
A small widget placed to the left of the text editor
|
|
10
|
+
that displays line numbers.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, editor):
|
|
14
|
+
super().__init__(editor)
|
|
15
|
+
self.setMouseTracking(True)
|
|
16
|
+
self._editor = editor
|
|
17
|
+
self.apply_stylesheet()
|
|
18
|
+
|
|
19
|
+
def sizeHint(self):
|
|
20
|
+
"""
|
|
21
|
+
The width is determined by the editor's
|
|
22
|
+
lineNumberAreaWidth() callback.
|
|
23
|
+
"""
|
|
24
|
+
return QSize(self._editor.lineNumberAreaWidth(), 0)
|
|
25
|
+
|
|
26
|
+
def paintEvent(self, event):
|
|
27
|
+
"""
|
|
28
|
+
Delegates painting to the editor's lineNumberAreaPaintEvent method.
|
|
29
|
+
"""
|
|
30
|
+
self._editor.lineNumberAreaPaintEvent(event)
|
|
31
|
+
|
|
32
|
+
def mouseMoveEvent(self, event):
|
|
33
|
+
self._editor.mouseMoveEvent(event)
|
|
34
|
+
|
|
35
|
+
def apply_stylesheet(self):
|
|
36
|
+
editor = self.parent()
|
|
37
|
+
if editor.code_editor_colors is not None:
|
|
38
|
+
self.setStyleSheet(f'''QWidget {{
|
|
39
|
+
color: {editor.code_editor_colors['line-number']};
|
|
40
|
+
background-color: {editor.code_editor_colors['background']};
|
|
41
|
+
font: {settings.font_size}pt '{settings.font_family}';
|
|
42
|
+
}}''')
|
|
43
|
+
|
|
44
|
+
class LineNumber:
|
|
45
|
+
"""
|
|
46
|
+
Mixin class that adds a line number area to a QPlainTextEdit.
|
|
47
|
+
The mixin assumes that it can access self.viewport() etc. from
|
|
48
|
+
QPlainTextEdit side of the inheritance.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, *args, **kwargs):
|
|
52
|
+
super().__init__(*args, **kwargs)
|
|
53
|
+
self.setMouseTracking(True)
|
|
54
|
+
# Create the line number area
|
|
55
|
+
self.lineNumberArea = LineNumberArea(self)
|
|
56
|
+
|
|
57
|
+
# Connect signals for updating
|
|
58
|
+
self.blockCountChanged.connect(self.updateLineNumberAreaWidth)
|
|
59
|
+
self.updateRequest.connect(self.updateLineNumberArea)
|
|
60
|
+
|
|
61
|
+
# Force an initial width update
|
|
62
|
+
self.updateLineNumberAreaWidth(0)
|
|
63
|
+
|
|
64
|
+
def lineNumberAreaSizeHint(self):
|
|
65
|
+
"""
|
|
66
|
+
Provide a size hint for the line number area,
|
|
67
|
+
used in the area itself.
|
|
68
|
+
"""
|
|
69
|
+
return self.size()
|
|
70
|
+
|
|
71
|
+
def lineNumberAreaWidth(self):
|
|
72
|
+
"""
|
|
73
|
+
Compute the width needed by the line number area based on the
|
|
74
|
+
number of digits in the total line count, plus some padding.
|
|
75
|
+
"""
|
|
76
|
+
digits = len(str(max(1, self.blockCount())))
|
|
77
|
+
# Add some extra space for margins
|
|
78
|
+
padding = 3
|
|
79
|
+
charWidth = self.fontMetrics().horizontalAdvance('9')
|
|
80
|
+
return padding + self.fontMetrics().horizontalAdvance('9' * digits) + charWidth
|
|
81
|
+
|
|
82
|
+
def updateLineNumberAreaWidth(self, _):
|
|
83
|
+
"""
|
|
84
|
+
Adjust left margin so the editor does not paint text under the lineNumbers.
|
|
85
|
+
"""
|
|
86
|
+
self.setViewportMargins(self.lineNumberAreaWidth(), 0, 0, 0)
|
|
87
|
+
|
|
88
|
+
def updateLineNumberArea(self, rect, dy):
|
|
89
|
+
"""
|
|
90
|
+
Called when the editor is scrolled or resized.
|
|
91
|
+
We move/scroll the lineNumberArea accordingly, and possibly repaint.
|
|
92
|
+
"""
|
|
93
|
+
if dy: # simply scroll
|
|
94
|
+
self.lineNumberArea.scroll(0, dy)
|
|
95
|
+
else:
|
|
96
|
+
# The entire area might need repainting
|
|
97
|
+
self.lineNumberArea.update(0, rect.y(), self.lineNumberArea.width(), rect.height())
|
|
98
|
+
|
|
99
|
+
# If resizing, update width as well
|
|
100
|
+
if rect.contains(self.viewport().rect()):
|
|
101
|
+
self.updateLineNumberAreaWidth(0)
|
|
102
|
+
|
|
103
|
+
def resizeEvent(self, event):
|
|
104
|
+
"""
|
|
105
|
+
Ensure lineNumberArea is properly resized and placed.
|
|
106
|
+
"""
|
|
107
|
+
super().resizeEvent(event)
|
|
108
|
+
cr = self.contentsRect()
|
|
109
|
+
self.lineNumberArea.setGeometry(
|
|
110
|
+
cr.left(), cr.top(), self.lineNumberAreaWidth(), cr.height()
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def _update_line_number_rects(self):
|
|
114
|
+
"""
|
|
115
|
+
Determines the rects around the line number area parts and the full
|
|
116
|
+
line. This is done for the entire viewport, even if we need to redraw
|
|
117
|
+
only a small part of the viewport. That way, the tooltip doesn't lose
|
|
118
|
+
track of where we're hovering.
|
|
119
|
+
"""
|
|
120
|
+
self._line_number_rects = {}
|
|
121
|
+
self._line_rects = {}
|
|
122
|
+
|
|
123
|
+
block = self.firstVisibleBlock()
|
|
124
|
+
block_number = block.blockNumber()
|
|
125
|
+
|
|
126
|
+
# Starting Y coordinate of the current block
|
|
127
|
+
top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top()
|
|
128
|
+
|
|
129
|
+
# We'll keep iterating until we run out of visible blocks
|
|
130
|
+
viewport_rect = self.viewport().rect()
|
|
131
|
+
viewport_bottom = viewport_rect.bottom()
|
|
132
|
+
viewport_width = viewport_rect.width()
|
|
133
|
+
rect_width = self.lineNumberArea.width()
|
|
134
|
+
while block.isValid() and top <= viewport_bottom:
|
|
135
|
+
rect_height = self.blockBoundingRect(block).height()
|
|
136
|
+
if block.isVisible():
|
|
137
|
+
# We only care about block geometry if visible
|
|
138
|
+
line_number_rect = QRect(0, int(top), rect_width, int(rect_height))
|
|
139
|
+
self._line_number_rects[block_number] = line_number_rect
|
|
140
|
+
line_rect = QRect(
|
|
141
|
+
-rect_width,
|
|
142
|
+
line_number_rect.top(),
|
|
143
|
+
viewport_width + rect_width,
|
|
144
|
+
line_number_rect.height()
|
|
145
|
+
)
|
|
146
|
+
self._line_rects[block_number] = line_rect
|
|
147
|
+
block = block.next()
|
|
148
|
+
top += rect_height
|
|
149
|
+
block_number += 1
|
|
150
|
+
|
|
151
|
+
def lineNumberAreaPaintEvent(self, event):
|
|
152
|
+
"""
|
|
153
|
+
Paint line numbers for each visible block, assuming self._line_number_rects
|
|
154
|
+
is already populated with the correct geometry for each visible line.
|
|
155
|
+
"""
|
|
156
|
+
self._update_line_number_rects()
|
|
157
|
+
painter = QPainter(self.lineNumberArea)
|
|
158
|
+
base_color = self.lineNumberArea.palette().base()
|
|
159
|
+
text_color = self.lineNumberArea.palette().text().color()
|
|
160
|
+
align_top_right = Qt.AlignTop | Qt.AlignRight
|
|
161
|
+
painter.fillRect(event.rect(), base_color)
|
|
162
|
+
for block_number, line_rect in self._line_number_rects.items():
|
|
163
|
+
if line_rect.intersects(event.rect()):
|
|
164
|
+
# Convert zero-based block_number to 1-based for display
|
|
165
|
+
line_num = block_number + 1
|
|
166
|
+
# Check if current line has a lint/check result
|
|
167
|
+
annotation = self.code_editor_line_annotations.get(line_num)
|
|
168
|
+
if annotation:
|
|
169
|
+
text_str = annotation['text']
|
|
170
|
+
painter.fillRect(line_rect, annotation['background_color'])
|
|
171
|
+
painter.setPen(annotation['color'])
|
|
172
|
+
else:
|
|
173
|
+
text_str = str(line_num)
|
|
174
|
+
painter.setPen(text_color)
|
|
175
|
+
painter.drawText(line_rect, align_top_right, text_str)
|
|
176
|
+
|
|
177
|
+
def mouseMoveEvent(self, event):
|
|
178
|
+
"""
|
|
179
|
+
Called when the mouse moves over the entire QPlainTextEdit.
|
|
180
|
+
We check each line's rectangle in self._line_rects
|
|
181
|
+
to see if the cursor is in that rect (covering both gutter + text).
|
|
182
|
+
If so, we look up code_editor_line_annotations to see if there's
|
|
183
|
+
an issue for that line, and show a tooltip. If not, hide it.
|
|
184
|
+
"""
|
|
185
|
+
pos = event.pos()
|
|
186
|
+
hovered_any_line = False
|
|
187
|
+
for block_number, line_rect in self._line_rects.items():
|
|
188
|
+
if line_rect.contains(pos):
|
|
189
|
+
hovered_any_line = True
|
|
190
|
+
line_num = block_number + 1
|
|
191
|
+
annotation = self.code_editor_line_annotations.get(line_num)
|
|
192
|
+
if annotation:
|
|
193
|
+
# Convert from local to global coords
|
|
194
|
+
line_number_rect = self._line_number_rects[block_number]
|
|
195
|
+
global_pos = self.mapToGlobal(line_number_rect.bottomRight())
|
|
196
|
+
QToolTip.showText(global_pos, annotation['tooltip'], self)
|
|
197
|
+
else:
|
|
198
|
+
QToolTip.hideText()
|
|
199
|
+
break
|
|
200
|
+
if not hovered_any_line:
|
|
201
|
+
QToolTip.hideText()
|
|
202
|
+
super().mouseMoveEvent(event)
|
|
203
|
+
|
|
204
|
+
def update_theme(self):
|
|
205
|
+
super().update_theme()
|
|
206
|
+
self.lineNumberArea.apply_stylesheet()
|