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.
Files changed (73) hide show
  1. pyqt_code_editor/__init__.py +5 -0
  2. pyqt_code_editor/_settings.py +232 -0
  3. pyqt_code_editor/app.py +438 -0
  4. pyqt_code_editor/code_editors/__init__.py +1 -0
  5. pyqt_code_editor/code_editors/code_editors.py +33 -0
  6. pyqt_code_editor/code_editors/languages/__init__.py +0 -0
  7. pyqt_code_editor/code_editors/languages/generic.py +11 -0
  8. pyqt_code_editor/code_editors/languages/markdown.py +11 -0
  9. pyqt_code_editor/code_editors/languages/python.py +13 -0
  10. pyqt_code_editor/code_editors/languages/text.py +9 -0
  11. pyqt_code_editor/components/__init__.py +0 -0
  12. pyqt_code_editor/components/editor_panel.py +399 -0
  13. pyqt_code_editor/components/find_in_files.py +199 -0
  14. pyqt_code_editor/components/jupyter_console.py +487 -0
  15. pyqt_code_editor/components/project_explorer.py +515 -0
  16. pyqt_code_editor/components/settings_panel.py +162 -0
  17. pyqt_code_editor/components/sigmund.py +75 -0
  18. pyqt_code_editor/components/workspace_explorer.py +162 -0
  19. pyqt_code_editor/mixins/__init__.py +15 -0
  20. pyqt_code_editor/mixins/auto_pair.py +201 -0
  21. pyqt_code_editor/mixins/base.py +142 -0
  22. pyqt_code_editor/mixins/check.py +94 -0
  23. pyqt_code_editor/mixins/comment.py +91 -0
  24. pyqt_code_editor/mixins/complete.py +482 -0
  25. pyqt_code_editor/mixins/execute.py +182 -0
  26. pyqt_code_editor/mixins/file_link.py +166 -0
  27. pyqt_code_editor/mixins/line_number.py +206 -0
  28. pyqt_code_editor/mixins/python_auto_indent.py +239 -0
  29. pyqt_code_editor/mixins/search_replace.py +396 -0
  30. pyqt_code_editor/mixins/shortcuts.py +228 -0
  31. pyqt_code_editor/mixins/smart_backspace_delete.py +120 -0
  32. pyqt_code_editor/mixins/symbols.py +45 -0
  33. pyqt_code_editor/mixins/theme.py +99 -0
  34. pyqt_code_editor/mixins/widgets/__init__.py +0 -0
  35. pyqt_code_editor/mixins/zoom.py +51 -0
  36. pyqt_code_editor/python_utils.py +936 -0
  37. pyqt_code_editor/signal_router.py +116 -0
  38. pyqt_code_editor/syntax_highlighters/__init__.py +0 -0
  39. pyqt_code_editor/syntax_highlighters/languages/__init__.py +0 -0
  40. pyqt_code_editor/syntax_highlighters/languages/generic.py +86 -0
  41. pyqt_code_editor/syntax_highlighters/languages/python.py +62 -0
  42. pyqt_code_editor/syntax_highlighters/syntax_highlighter.py +26 -0
  43. pyqt_code_editor/themes.py +36 -0
  44. pyqt_code_editor/utils.py +96 -0
  45. pyqt_code_editor/watchdog.py +141 -0
  46. pyqt_code_editor/widgets/__init__.py +6 -0
  47. pyqt_code_editor/widgets/calltip_widget.py +44 -0
  48. pyqt_code_editor/widgets/completion_popup.py +89 -0
  49. pyqt_code_editor/widgets/dock.py +15 -0
  50. pyqt_code_editor/widgets/quick_open_dialog.py +196 -0
  51. pyqt_code_editor/widgets/quick_open_file_dialog.py +32 -0
  52. pyqt_code_editor/widgets/quick_symbol_dialog.py +13 -0
  53. pyqt_code_editor/widgets/search_replace_frame.py +128 -0
  54. pyqt_code_editor/widgets/tab_splitter.py +22 -0
  55. pyqt_code_editor/widgets/tabbed_editor.py +351 -0
  56. pyqt_code_editor/worker/__init__.py +0 -0
  57. pyqt_code_editor/worker/languages/__init__.py +0 -0
  58. pyqt_code_editor/worker/languages/generic.py +15 -0
  59. pyqt_code_editor/worker/languages/python.py +17 -0
  60. pyqt_code_editor/worker/manager.py +113 -0
  61. pyqt_code_editor/worker/process.py +128 -0
  62. pyqt_code_editor/worker/providers/__init__.py +1 -0
  63. pyqt_code_editor/worker/providers/codestral.py +56 -0
  64. pyqt_code_editor/worker/providers/jedi.py +115 -0
  65. pyqt_code_editor/worker/providers/ruff.py +45 -0
  66. pyqt_code_editor/worker/providers/sigmund.py +29 -0
  67. pyqt_code_editor/worker/providers/symbol.py +78 -0
  68. pyqt_code_editor/worker/settings.py +1 -0
  69. pyqt_code_editor-0.0.1.dist-info/METADATA +69 -0
  70. pyqt_code_editor-0.0.1.dist-info/RECORD +73 -0
  71. pyqt_code_editor-0.0.1.dist-info/WHEEL +4 -0
  72. pyqt_code_editor-0.0.1.dist-info/entry_points.txt +4 -0
  73. 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()