sqlshell 0.4.4__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.
- sqlshell/__init__.py +84 -0
- sqlshell/__main__.py +4926 -0
- sqlshell/ai_autocomplete.py +392 -0
- sqlshell/ai_settings_dialog.py +337 -0
- sqlshell/context_suggester.py +768 -0
- sqlshell/create_test_data.py +152 -0
- sqlshell/data/create_test_data.py +137 -0
- sqlshell/db/__init__.py +6 -0
- sqlshell/db/database_manager.py +1318 -0
- sqlshell/db/export_manager.py +188 -0
- sqlshell/editor.py +1166 -0
- sqlshell/editor_integration.py +127 -0
- sqlshell/execution_handler.py +421 -0
- sqlshell/menus.py +262 -0
- sqlshell/notification_manager.py +370 -0
- sqlshell/query_tab.py +904 -0
- sqlshell/resources/__init__.py +1 -0
- sqlshell/resources/icon.png +0 -0
- sqlshell/resources/logo_large.png +0 -0
- sqlshell/resources/logo_medium.png +0 -0
- sqlshell/resources/logo_small.png +0 -0
- sqlshell/resources/splash_screen.gif +0 -0
- sqlshell/space_invaders.py +501 -0
- sqlshell/splash_screen.py +405 -0
- sqlshell/sqlshell/__init__.py +5 -0
- sqlshell/sqlshell/create_test_data.py +118 -0
- sqlshell/sqlshell/create_test_databases.py +96 -0
- sqlshell/sqlshell_demo.png +0 -0
- sqlshell/styles.py +257 -0
- sqlshell/suggester_integration.py +330 -0
- sqlshell/syntax_highlighter.py +124 -0
- sqlshell/table_list.py +996 -0
- sqlshell/ui/__init__.py +6 -0
- sqlshell/ui/bar_chart_delegate.py +49 -0
- sqlshell/ui/filter_header.py +469 -0
- sqlshell/utils/__init__.py +16 -0
- sqlshell/utils/profile_cn2.py +1661 -0
- sqlshell/utils/profile_column.py +2635 -0
- sqlshell/utils/profile_distributions.py +616 -0
- sqlshell/utils/profile_entropy.py +347 -0
- sqlshell/utils/profile_foreign_keys.py +779 -0
- sqlshell/utils/profile_keys.py +2834 -0
- sqlshell/utils/profile_ohe.py +934 -0
- sqlshell/utils/profile_ohe_advanced.py +754 -0
- sqlshell/utils/profile_ohe_comparison.py +237 -0
- sqlshell/utils/profile_prediction.py +926 -0
- sqlshell/utils/profile_similarity.py +876 -0
- sqlshell/utils/search_in_df.py +90 -0
- sqlshell/widgets.py +400 -0
- sqlshell-0.4.4.dist-info/METADATA +441 -0
- sqlshell-0.4.4.dist-info/RECORD +54 -0
- sqlshell-0.4.4.dist-info/WHEEL +5 -0
- sqlshell-0.4.4.dist-info/entry_points.txt +2 -0
- sqlshell-0.4.4.dist-info/top_level.txt +1 -0
sqlshell/editor.py
ADDED
|
@@ -0,0 +1,1166 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQL Editor with research-backed UX improvements for optimal coding comfort.
|
|
3
|
+
|
|
4
|
+
Typography & Readability Improvements:
|
|
5
|
+
--------------------------------------
|
|
6
|
+
1. FONT SELECTION:
|
|
7
|
+
- Prioritizes modern coding fonts (JetBrains Mono, Fira Code, Source Code Pro)
|
|
8
|
+
- Falls back gracefully across platforms (Windows/Linux/macOS)
|
|
9
|
+
- Full hinting enabled for crisp rendering at all sizes
|
|
10
|
+
|
|
11
|
+
2. LINE SPACING:
|
|
12
|
+
- 1.5x line height (150%) based on readability research
|
|
13
|
+
- Studies show 1.4-1.6x improves comprehension without wasting space
|
|
14
|
+
- Reduces eye strain during extended coding sessions
|
|
15
|
+
|
|
16
|
+
3. COLOR CONTRAST:
|
|
17
|
+
- All syntax colors meet WCAG AA standards (4.5:1 contrast ratio)
|
|
18
|
+
- Keywords: #0066CC (darker blue, better than bright blue)
|
|
19
|
+
- Comments: #6A737D (GitHub's tested gray)
|
|
20
|
+
- Ghost text: #999999 with italic styling for clear differentiation
|
|
21
|
+
|
|
22
|
+
4. FONT RENDERING:
|
|
23
|
+
- Anti-aliasing enabled for smooth text rendering
|
|
24
|
+
- Kerning disabled for monospace consistency
|
|
25
|
+
- StyleHint.Monospace ensures proper fallback behavior
|
|
26
|
+
|
|
27
|
+
5. VISUAL COMFORT:
|
|
28
|
+
- Current line highlighting in gutter for better position awareness
|
|
29
|
+
- Subtle background (#F6F8FA) reduces harsh white glare
|
|
30
|
+
- Rounded corners and proper padding reduce visual harshness
|
|
31
|
+
|
|
32
|
+
Research References:
|
|
33
|
+
- Typography for Developers (Butterick's Practical Typography)
|
|
34
|
+
- WCAG 2.1 Guidelines for Visual Accessibility
|
|
35
|
+
- VS Code, JetBrains IDE design patterns
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from PyQt6.QtWidgets import QPlainTextEdit, QWidget, QCompleter
|
|
39
|
+
from PyQt6.QtCore import Qt, QSize, QRect, QStringListModel, QTimer
|
|
40
|
+
from PyQt6.QtGui import QFont, QColor, QTextCursor, QPainter, QBrush
|
|
41
|
+
import re
|
|
42
|
+
|
|
43
|
+
class LineNumberArea(QWidget):
|
|
44
|
+
def __init__(self, editor):
|
|
45
|
+
super().__init__(editor)
|
|
46
|
+
self.editor = editor
|
|
47
|
+
|
|
48
|
+
def sizeHint(self):
|
|
49
|
+
return QSize(self.editor.line_number_area_width(), 0)
|
|
50
|
+
|
|
51
|
+
def paintEvent(self, event):
|
|
52
|
+
self.editor.line_number_area_paint_event(event)
|
|
53
|
+
|
|
54
|
+
class SQLEditor(QPlainTextEdit):
|
|
55
|
+
def __init__(self, parent=None):
|
|
56
|
+
super().__init__(parent)
|
|
57
|
+
self.line_number_area = LineNumberArea(self)
|
|
58
|
+
|
|
59
|
+
# Set monospaced font with fallbacks for cross-platform support
|
|
60
|
+
# Research shows monospace fonts at 11-13pt are optimal for code readability
|
|
61
|
+
font_families = [
|
|
62
|
+
"JetBrains Mono", # Excellent readability, designed for code
|
|
63
|
+
"Fira Code", # Good ligatures and readability
|
|
64
|
+
"Source Code Pro", # Adobe's coding font
|
|
65
|
+
"DejaVu Sans Mono", # Good Linux default
|
|
66
|
+
"Consolas", # Windows
|
|
67
|
+
"Monaco", # macOS
|
|
68
|
+
"Courier New" # Universal fallback
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
font = QFont()
|
|
72
|
+
for font_family in font_families:
|
|
73
|
+
font.setFamily(font_family)
|
|
74
|
+
if font.family() == font_family or font_family == "Courier New":
|
|
75
|
+
break
|
|
76
|
+
|
|
77
|
+
font.setPointSize(12) # Optimal size for code (research: 11-13pt)
|
|
78
|
+
font.setFixedPitch(True)
|
|
79
|
+
font.setStyleHint(QFont.StyleHint.Monospace)
|
|
80
|
+
font.setHintingPreference(QFont.HintingPreference.PreferFullHinting) # Better rendering
|
|
81
|
+
font.setKerning(False) # Disable kerning for monospace consistency
|
|
82
|
+
self.setFont(font)
|
|
83
|
+
|
|
84
|
+
# Connect signals
|
|
85
|
+
self.blockCountChanged.connect(self.update_line_number_area_width)
|
|
86
|
+
self.updateRequest.connect(self.update_line_number_area)
|
|
87
|
+
|
|
88
|
+
# Initialize
|
|
89
|
+
self.update_line_number_area_width(0)
|
|
90
|
+
|
|
91
|
+
# Set tab width to 4 spaces
|
|
92
|
+
self.setTabStopDistance(4 * self.fontMetrics().horizontalAdvance(' '))
|
|
93
|
+
|
|
94
|
+
# Line spacing: Research shows 1.4-1.6x line height improves readability
|
|
95
|
+
# QPlainTextEdit uses block height, so we add extra spacing
|
|
96
|
+
self.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) # Common in code editors
|
|
97
|
+
|
|
98
|
+
# Set placeholder text
|
|
99
|
+
self.setPlaceholderText("Enter your SQL query here...")
|
|
100
|
+
|
|
101
|
+
# Set modern selection color with proper contrast (WCAG AA compliant)
|
|
102
|
+
self.selection_color = QColor("#3498DB")
|
|
103
|
+
self.selection_color.setAlpha(50) # Make it semi-transparent
|
|
104
|
+
|
|
105
|
+
# Ghost text completion variables
|
|
106
|
+
self.ghost_text = ""
|
|
107
|
+
self.ghost_text_position = -1
|
|
108
|
+
self.ghost_text_suggestion = ""
|
|
109
|
+
self.ghost_text_partial_word = "" # The partial word that was being completed
|
|
110
|
+
# Ghost text color: 4.5:1 contrast ratio for WCAG AA compliance on white background
|
|
111
|
+
self.ghost_text_color = QColor("#999999") # Lighter gray with better contrast
|
|
112
|
+
|
|
113
|
+
# Apply stylesheet for enhanced visual comfort
|
|
114
|
+
self.setStyleSheet("""
|
|
115
|
+
QPlainTextEdit {
|
|
116
|
+
background-color: #FFFFFF;
|
|
117
|
+
color: #2C3E50;
|
|
118
|
+
border: 1px solid #D0D7DE;
|
|
119
|
+
border-radius: 6px;
|
|
120
|
+
padding: 8px;
|
|
121
|
+
selection-background-color: rgba(52, 152, 219, 0.3);
|
|
122
|
+
}
|
|
123
|
+
QPlainTextEdit:focus {
|
|
124
|
+
border: 1px solid #3498DB;
|
|
125
|
+
outline: none;
|
|
126
|
+
}
|
|
127
|
+
""")
|
|
128
|
+
|
|
129
|
+
# SQL keywords for syntax highlighting and autocompletion
|
|
130
|
+
self.sql_keywords = {
|
|
131
|
+
'basic': [
|
|
132
|
+
"SELECT", "FROM", "WHERE", "INSERT", "UPDATE", "DELETE", "CREATE", "DROP",
|
|
133
|
+
"ALTER", "TABLE", "VIEW", "INDEX", "TRIGGER", "PROCEDURE", "FUNCTION",
|
|
134
|
+
"AS", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "IS NULL", "IS NOT NULL",
|
|
135
|
+
"ORDER BY", "GROUP BY", "HAVING", "LIMIT", "OFFSET", "TOP", "DISTINCT",
|
|
136
|
+
"ON", "SET", "VALUES", "INTO", "DEFAULT", "PRIMARY KEY", "FOREIGN KEY",
|
|
137
|
+
"JOIN", "LEFT JOIN", "RIGHT JOIN", "INNER JOIN", "FULL JOIN",
|
|
138
|
+
"CASE", "WHEN", "THEN", "ELSE", "END", "IF", "BEGIN", "END", "COMMIT",
|
|
139
|
+
"ROLLBACK"
|
|
140
|
+
],
|
|
141
|
+
'aggregation': [
|
|
142
|
+
"SUM", "AVG", "COUNT", "MIN", "MAX", "STDDEV", "VARIANCE", "FIRST",
|
|
143
|
+
"LAST", "GROUP_CONCAT"
|
|
144
|
+
],
|
|
145
|
+
'join': [
|
|
146
|
+
"INNER JOIN", "LEFT JOIN", "RIGHT JOIN", "FULL JOIN", "CROSS JOIN",
|
|
147
|
+
"JOIN ... ON", "JOIN ... USING", "NATURAL JOIN"
|
|
148
|
+
],
|
|
149
|
+
'functions': [
|
|
150
|
+
"SUBSTR", "SUBSTRING", "UPPER", "LOWER", "TRIM", "LTRIM", "RTRIM",
|
|
151
|
+
"LENGTH", "CONCAT", "REPLACE", "INSTR", "CAST", "CONVERT", "COALESCE",
|
|
152
|
+
"NULLIF", "NVL", "IFNULL", "DECODE", "ROUND", "TRUNC", "FLOOR", "CEILING",
|
|
153
|
+
"ABS", "MOD", "DATE", "TIME", "DATETIME", "TIMESTAMP", "EXTRACT", "DATEADD",
|
|
154
|
+
"DATEDIFF", "CURRENT_DATE", "CURRENT_TIME", "CURRENT_TIMESTAMP"
|
|
155
|
+
],
|
|
156
|
+
'table_ops': [
|
|
157
|
+
"INSERT INTO", "UPDATE", "DELETE FROM", "CREATE TABLE", "DROP TABLE",
|
|
158
|
+
"ALTER TABLE", "ADD COLUMN", "DROP COLUMN", "MODIFY COLUMN", "RENAME TO",
|
|
159
|
+
"TRUNCATE TABLE", "VACUUM"
|
|
160
|
+
],
|
|
161
|
+
'types': [
|
|
162
|
+
"INTEGER", "INT", "BIGINT", "SMALLINT", "TINYINT", "NUMERIC",
|
|
163
|
+
"DECIMAL", "FLOAT", "REAL", "DOUBLE", "BOOLEAN", "CHAR",
|
|
164
|
+
"VARCHAR", "TEXT", "DATE", "TIME", "TIMESTAMP", "INTERVAL",
|
|
165
|
+
"UUID", "JSON", "JSONB", "ARRAY", "BLOB"
|
|
166
|
+
],
|
|
167
|
+
'window': [
|
|
168
|
+
"OVER (", "PARTITION BY", "ORDER BY", "ROWS BETWEEN", "RANGE BETWEEN",
|
|
169
|
+
"UNBOUNDED PRECEDING", "CURRENT ROW", "UNBOUNDED FOLLOWING",
|
|
170
|
+
"ROW_NUMBER()", "RANK()", "DENSE_RANK()", "LEAD(", "LAG("
|
|
171
|
+
],
|
|
172
|
+
'other': [
|
|
173
|
+
"WITH", "UNION", "UNION ALL", "INTERSECT", "EXCEPT", "DISTINCT",
|
|
174
|
+
"ALL", "ANY", "SOME", "RECURSIVE", "GROUPING SETS", "CUBE", "ROLLUP"
|
|
175
|
+
]
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
# Flattened list of all SQL keywords
|
|
179
|
+
self.all_sql_keywords = []
|
|
180
|
+
for category in self.sql_keywords.values():
|
|
181
|
+
self.all_sql_keywords.extend(category)
|
|
182
|
+
|
|
183
|
+
# Common SQL patterns with placeholders
|
|
184
|
+
self.sql_patterns = [
|
|
185
|
+
"SELECT * FROM $table WHERE $column = $value",
|
|
186
|
+
"SELECT $columns FROM $table GROUP BY $column HAVING $condition",
|
|
187
|
+
"SELECT $columns FROM $table ORDER BY $column $direction LIMIT $limit",
|
|
188
|
+
"SELECT $table1.$column1, $table2.$column2 FROM $table1 JOIN $table2 ON $table1.$column = $table2.$column",
|
|
189
|
+
"INSERT INTO $table ($columns) VALUES ($values)",
|
|
190
|
+
"UPDATE $table SET $column = $value WHERE $condition",
|
|
191
|
+
"DELETE FROM $table WHERE $condition",
|
|
192
|
+
"WITH $cte AS (SELECT * FROM $table) SELECT * FROM $cte WHERE $condition"
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
# Initialize completer with SQL keywords (keep for compatibility but disable popup)
|
|
196
|
+
self.completer = None
|
|
197
|
+
self.set_completer(QCompleter(self.all_sql_keywords))
|
|
198
|
+
|
|
199
|
+
# Track last key press for better completion behavior
|
|
200
|
+
self.last_key_was_tab = False
|
|
201
|
+
|
|
202
|
+
# Tables and columns cache for context-aware completion
|
|
203
|
+
self.tables_cache = {}
|
|
204
|
+
self.last_update_time = 0
|
|
205
|
+
|
|
206
|
+
# Enable drag and drop
|
|
207
|
+
self.setAcceptDrops(True)
|
|
208
|
+
|
|
209
|
+
# Apply improved line spacing for better readability
|
|
210
|
+
self._apply_line_spacing()
|
|
211
|
+
|
|
212
|
+
def _apply_line_spacing(self):
|
|
213
|
+
"""Apply optimal line spacing for code readability.
|
|
214
|
+
Research shows 1.4-1.6x line height improves readability without wasting space.
|
|
215
|
+
"""
|
|
216
|
+
from PyQt6.QtGui import QTextBlockFormat
|
|
217
|
+
|
|
218
|
+
# Get the current block format
|
|
219
|
+
block_format = QTextBlockFormat()
|
|
220
|
+
|
|
221
|
+
# Set line height to 1.5x (150%) - optimal for code readability
|
|
222
|
+
# Using percentage mode for consistent spacing across zoom levels
|
|
223
|
+
block_format.setLineHeight(150, QTextBlockFormat.LineHeightTypes.ProportionalHeight.value)
|
|
224
|
+
|
|
225
|
+
# Apply to the default text format
|
|
226
|
+
cursor = self.textCursor()
|
|
227
|
+
cursor.select(QTextCursor.SelectionType.Document)
|
|
228
|
+
cursor.mergeBlockFormat(block_format)
|
|
229
|
+
cursor.clearSelection()
|
|
230
|
+
self.setTextCursor(cursor)
|
|
231
|
+
|
|
232
|
+
def clear_ghost_text(self):
|
|
233
|
+
"""Clear the ghost text and update the display"""
|
|
234
|
+
if self.ghost_text:
|
|
235
|
+
self.ghost_text = ""
|
|
236
|
+
self.ghost_text_position = -1
|
|
237
|
+
self.ghost_text_suggestion = ""
|
|
238
|
+
self.ghost_text_partial_word = ""
|
|
239
|
+
self.viewport().update() # Trigger a repaint
|
|
240
|
+
|
|
241
|
+
def show_ghost_text(self, suggestion, position):
|
|
242
|
+
"""Show ghost text suggestion at the given position"""
|
|
243
|
+
# Verify the cursor is still at the expected position
|
|
244
|
+
if self.textCursor().position() != position:
|
|
245
|
+
# Cursor has moved, don't show ghost text
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
self.ghost_text_suggestion = suggestion
|
|
249
|
+
self.ghost_text_position = position
|
|
250
|
+
|
|
251
|
+
# Get current word to calculate what part to show as ghost
|
|
252
|
+
current_word = self.get_word_under_cursor()
|
|
253
|
+
self.ghost_text_partial_word = current_word # Store the partial word
|
|
254
|
+
|
|
255
|
+
if suggestion.lower().startswith(current_word.lower()):
|
|
256
|
+
# Show only the part that hasn't been typed yet
|
|
257
|
+
self.ghost_text = suggestion[len(current_word):]
|
|
258
|
+
else:
|
|
259
|
+
self.ghost_text = suggestion
|
|
260
|
+
|
|
261
|
+
# Schedule a viewport update without moving cursor
|
|
262
|
+
self.viewport().update() # Trigger a repaint
|
|
263
|
+
|
|
264
|
+
def accept_ghost_text(self):
|
|
265
|
+
"""Accept the current ghost text suggestion"""
|
|
266
|
+
if not self.ghost_text_suggestion:
|
|
267
|
+
return False
|
|
268
|
+
|
|
269
|
+
# Get a fresh cursor and save the current position
|
|
270
|
+
cursor = self.textCursor()
|
|
271
|
+
original_position = cursor.position()
|
|
272
|
+
|
|
273
|
+
# Verify we're still at the expected position (allow small tolerance for continued typing)
|
|
274
|
+
if self.ghost_text_position >= 0:
|
|
275
|
+
# Calculate how many characters were typed since ghost text was shown
|
|
276
|
+
chars_typed = original_position - self.ghost_text_position
|
|
277
|
+
|
|
278
|
+
# If too many characters were typed, or cursor moved backwards, reject
|
|
279
|
+
if chars_typed < 0 or chars_typed > len(self.ghost_text_partial_word) + 10:
|
|
280
|
+
self.clear_ghost_text()
|
|
281
|
+
return False
|
|
282
|
+
|
|
283
|
+
# Get the current word under cursor
|
|
284
|
+
current_word = self.get_word_under_cursor()
|
|
285
|
+
|
|
286
|
+
# Check if suggestion is a full word replacement or just a completion suffix
|
|
287
|
+
is_full_replacement = self.ghost_text_suggestion.lower().startswith(current_word.lower()) if current_word else True
|
|
288
|
+
|
|
289
|
+
# Begin editing block to ensure atomic operation
|
|
290
|
+
cursor.beginEditBlock()
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
if is_full_replacement:
|
|
294
|
+
# Full replacement: suggestion starts with current word
|
|
295
|
+
# Validate that current word is still a prefix of suggestion
|
|
296
|
+
if current_word and not self.ghost_text_suggestion.lower().startswith(current_word.lower()):
|
|
297
|
+
# User has typed something that doesn't match the suggestion
|
|
298
|
+
cursor.endEditBlock()
|
|
299
|
+
self.clear_ghost_text()
|
|
300
|
+
return False
|
|
301
|
+
|
|
302
|
+
# Delete the current word and replace with full suggestion
|
|
303
|
+
if current_word:
|
|
304
|
+
# Move back to select the partial word
|
|
305
|
+
for _ in range(len(current_word)):
|
|
306
|
+
cursor.movePosition(QTextCursor.MoveOperation.PreviousCharacter,
|
|
307
|
+
QTextCursor.MoveMode.KeepAnchor)
|
|
308
|
+
cursor.removeSelectedText()
|
|
309
|
+
|
|
310
|
+
# Insert the full suggestion
|
|
311
|
+
cursor.insertText(self.ghost_text_suggestion)
|
|
312
|
+
else:
|
|
313
|
+
# Completion suffix: just append the suggestion to current text
|
|
314
|
+
# Validate: current word should still match the original partial word we started with
|
|
315
|
+
if self.ghost_text_partial_word and current_word:
|
|
316
|
+
# Check if current word still starts with the original partial word
|
|
317
|
+
if not current_word.lower().startswith(self.ghost_text_partial_word.lower()):
|
|
318
|
+
# User has typed something that doesn't match original context
|
|
319
|
+
cursor.endEditBlock()
|
|
320
|
+
self.clear_ghost_text()
|
|
321
|
+
return False
|
|
322
|
+
|
|
323
|
+
# No need to delete anything, just insert at current position
|
|
324
|
+
cursor.insertText(self.ghost_text_suggestion)
|
|
325
|
+
|
|
326
|
+
# End editing block
|
|
327
|
+
cursor.endEditBlock()
|
|
328
|
+
|
|
329
|
+
# Update the editor's cursor
|
|
330
|
+
self.setTextCursor(cursor)
|
|
331
|
+
|
|
332
|
+
except Exception as e:
|
|
333
|
+
# If anything goes wrong, end the edit block and restore cursor
|
|
334
|
+
cursor.endEditBlock()
|
|
335
|
+
print(f"Error accepting ghost text: {e}")
|
|
336
|
+
self.clear_ghost_text()
|
|
337
|
+
return False
|
|
338
|
+
|
|
339
|
+
# Clear ghost text
|
|
340
|
+
self.clear_ghost_text()
|
|
341
|
+
return True
|
|
342
|
+
|
|
343
|
+
def set_completer(self, completer):
|
|
344
|
+
"""Set the completer for the editor (modified to disable popup)"""
|
|
345
|
+
if self.completer:
|
|
346
|
+
try:
|
|
347
|
+
self.completer.disconnect(self)
|
|
348
|
+
except Exception:
|
|
349
|
+
pass # Ignore errors when disconnecting
|
|
350
|
+
|
|
351
|
+
self.completer = completer
|
|
352
|
+
|
|
353
|
+
if not self.completer:
|
|
354
|
+
return
|
|
355
|
+
|
|
356
|
+
self.completer.setWidget(self)
|
|
357
|
+
# Set to UnfilteredPopupCompletion but we'll handle it manually
|
|
358
|
+
self.completer.setCompletionMode(QCompleter.CompletionMode.UnfilteredPopupCompletion)
|
|
359
|
+
self.completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
|
|
360
|
+
# Don't connect activated signal since we're not using popups
|
|
361
|
+
|
|
362
|
+
def update_completer_model(self, words_or_model):
|
|
363
|
+
"""Update the completer model with new words or a new model
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
words_or_model: Either a list of words or a QStringListModel
|
|
367
|
+
"""
|
|
368
|
+
if not self.completer:
|
|
369
|
+
# Create a completer if none exists
|
|
370
|
+
self.set_completer(QCompleter(self.all_sql_keywords))
|
|
371
|
+
if not self.completer:
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
# If a model is passed directly, use it
|
|
375
|
+
if isinstance(words_or_model, QStringListModel):
|
|
376
|
+
try:
|
|
377
|
+
# Update our tables and columns cache for context-aware completion
|
|
378
|
+
words = words_or_model.stringList()
|
|
379
|
+
self._update_tables_cache(words)
|
|
380
|
+
self.completer.setModel(words_or_model)
|
|
381
|
+
except Exception as e:
|
|
382
|
+
# If there's an error, fall back to just SQL keywords
|
|
383
|
+
model = QStringListModel()
|
|
384
|
+
model.setStringList(self.all_sql_keywords)
|
|
385
|
+
self.completer.setModel(model)
|
|
386
|
+
print(f"Error updating completer model: {e}")
|
|
387
|
+
|
|
388
|
+
return
|
|
389
|
+
|
|
390
|
+
try:
|
|
391
|
+
# Update tables cache
|
|
392
|
+
self._update_tables_cache(words_or_model)
|
|
393
|
+
|
|
394
|
+
# Otherwise, combine SQL keywords with table/column names and create a new model
|
|
395
|
+
# Use set operations for efficiency
|
|
396
|
+
words_set = set(words_or_model) # Remove duplicates
|
|
397
|
+
sql_keywords_set = set(self.all_sql_keywords)
|
|
398
|
+
all_words = list(sql_keywords_set.union(words_set))
|
|
399
|
+
|
|
400
|
+
# Sort the combined words for better autocomplete experience
|
|
401
|
+
all_words.sort(key=lambda x: (not x.isupper(), x)) # Prioritize SQL keywords (all uppercase)
|
|
402
|
+
|
|
403
|
+
# Create an optimized model with all words
|
|
404
|
+
model = QStringListModel()
|
|
405
|
+
model.setStringList(all_words)
|
|
406
|
+
|
|
407
|
+
# Set the model to the completer
|
|
408
|
+
self.completer.setModel(model)
|
|
409
|
+
except Exception as e:
|
|
410
|
+
# If there's an error, fall back to just SQL keywords
|
|
411
|
+
model = QStringListModel()
|
|
412
|
+
model.setStringList(self.all_sql_keywords)
|
|
413
|
+
self.completer.setModel(model)
|
|
414
|
+
print(f"Error updating completer with words: {e}")
|
|
415
|
+
|
|
416
|
+
def _update_tables_cache(self, words):
|
|
417
|
+
"""Update internal tables and columns cache from word list"""
|
|
418
|
+
self.tables_cache = {}
|
|
419
|
+
|
|
420
|
+
# Create a map of tables to columns
|
|
421
|
+
for word in words:
|
|
422
|
+
if '.' in word:
|
|
423
|
+
# This is a qualified column (table.column)
|
|
424
|
+
parts = word.split('.')
|
|
425
|
+
if len(parts) == 2:
|
|
426
|
+
table, column = parts
|
|
427
|
+
if table not in self.tables_cache:
|
|
428
|
+
self.tables_cache[table] = []
|
|
429
|
+
if column not in self.tables_cache[table]:
|
|
430
|
+
self.tables_cache[table].append(column)
|
|
431
|
+
else:
|
|
432
|
+
# Could be a table or a standalone column
|
|
433
|
+
# We'll assume tables as being words that don't have special characters
|
|
434
|
+
if not any(c in word for c in ',;()[]+-*/=<>%|&!?:'):
|
|
435
|
+
# Add as potential table
|
|
436
|
+
if word not in self.tables_cache:
|
|
437
|
+
self.tables_cache[word] = []
|
|
438
|
+
|
|
439
|
+
def get_word_under_cursor(self):
|
|
440
|
+
"""Get the complete word under the cursor for completion, handling dot notation"""
|
|
441
|
+
tc = self.textCursor()
|
|
442
|
+
current_position = tc.position()
|
|
443
|
+
|
|
444
|
+
# Get the current line of text
|
|
445
|
+
tc.select(QTextCursor.SelectionType.LineUnderCursor)
|
|
446
|
+
line_text = tc.selectedText()
|
|
447
|
+
|
|
448
|
+
# Calculate cursor position within the line
|
|
449
|
+
start_of_line_pos = current_position - tc.selectionStart()
|
|
450
|
+
|
|
451
|
+
# Identify word boundaries including dots
|
|
452
|
+
start_pos = start_of_line_pos
|
|
453
|
+
while start_pos > 0 and (line_text[start_pos-1].isalnum() or line_text[start_pos-1] in '_$.'):
|
|
454
|
+
start_pos -= 1
|
|
455
|
+
|
|
456
|
+
end_pos = start_of_line_pos
|
|
457
|
+
while end_pos < len(line_text) and (line_text[end_pos].isalnum() or line_text[end_pos] in '_$'):
|
|
458
|
+
end_pos += 1
|
|
459
|
+
|
|
460
|
+
if start_pos == end_pos:
|
|
461
|
+
return ""
|
|
462
|
+
|
|
463
|
+
word = line_text[start_pos:end_pos]
|
|
464
|
+
return word
|
|
465
|
+
|
|
466
|
+
def text_under_cursor(self):
|
|
467
|
+
"""Get the text under cursor for standard completion behavior"""
|
|
468
|
+
# Get the complete word including table prefixes
|
|
469
|
+
word = self.get_word_under_cursor()
|
|
470
|
+
|
|
471
|
+
# For table.col completions, only return portion after the dot
|
|
472
|
+
if '.' in word and word.endswith('.'):
|
|
473
|
+
# For "table." return empty to trigger whole column list
|
|
474
|
+
return ""
|
|
475
|
+
elif '.' in word:
|
|
476
|
+
# For "table.co", return "co" for completion
|
|
477
|
+
return word.split('.')[-1]
|
|
478
|
+
|
|
479
|
+
# Otherwise return the whole word
|
|
480
|
+
return word
|
|
481
|
+
|
|
482
|
+
def insert_completion(self, completion):
|
|
483
|
+
"""Insert the completion text with enhanced context awareness"""
|
|
484
|
+
if self.completer.widget() != self:
|
|
485
|
+
return
|
|
486
|
+
|
|
487
|
+
tc = self.textCursor()
|
|
488
|
+
|
|
489
|
+
# Handle table.column completion differently
|
|
490
|
+
word = self.get_word_under_cursor()
|
|
491
|
+
if '.' in word and not word.endswith('.'):
|
|
492
|
+
# We're completing something like "table.co" to "table.column"
|
|
493
|
+
# Replace only the part after the last dot
|
|
494
|
+
prefix_parts = word.split('.')
|
|
495
|
+
prefix = '.'.join(prefix_parts[:-1]) + '.'
|
|
496
|
+
suffix = prefix_parts[-1]
|
|
497
|
+
|
|
498
|
+
# Get positions for text manipulation
|
|
499
|
+
cursor_pos = tc.position()
|
|
500
|
+
tc.setPosition(cursor_pos - len(suffix))
|
|
501
|
+
tc.setPosition(cursor_pos, QTextCursor.MoveMode.KeepAnchor)
|
|
502
|
+
tc.removeSelectedText()
|
|
503
|
+
tc.insertText(completion)
|
|
504
|
+
else:
|
|
505
|
+
# Standard completion behavior
|
|
506
|
+
current_prefix = self.completer.completionPrefix()
|
|
507
|
+
|
|
508
|
+
# When completing, replace the entire prefix with the completion
|
|
509
|
+
# This ensures exact matches are handled correctly
|
|
510
|
+
if current_prefix:
|
|
511
|
+
# Get positions for text manipulation
|
|
512
|
+
cursor_pos = tc.position()
|
|
513
|
+
tc.setPosition(cursor_pos - len(current_prefix))
|
|
514
|
+
tc.setPosition(cursor_pos, QTextCursor.MoveMode.KeepAnchor)
|
|
515
|
+
tc.removeSelectedText()
|
|
516
|
+
|
|
517
|
+
# Don't automatically add space when completing with Tab
|
|
518
|
+
# or when completion already ends with special characters
|
|
519
|
+
special_endings = ["(", ")", ",", ";", ".", "*"]
|
|
520
|
+
if any(completion.endswith(char) for char in special_endings):
|
|
521
|
+
tc.insertText(completion)
|
|
522
|
+
else:
|
|
523
|
+
# Add space for normal words, but only if activated with Enter/Return
|
|
524
|
+
# not when using Tab for completion
|
|
525
|
+
from_keyboard = self.sender() is None
|
|
526
|
+
add_space = from_keyboard or not self.last_key_was_tab
|
|
527
|
+
tc.insertText(completion + (" " if add_space else ""))
|
|
528
|
+
|
|
529
|
+
self.setTextCursor(tc)
|
|
530
|
+
|
|
531
|
+
def get_context_at_cursor(self):
|
|
532
|
+
"""Analyze the query to determine the current SQL context for smarter completions"""
|
|
533
|
+
# Get text up to cursor to analyze context
|
|
534
|
+
tc = self.textCursor()
|
|
535
|
+
position = tc.position()
|
|
536
|
+
|
|
537
|
+
# Select all text from start to cursor
|
|
538
|
+
doc = self.document()
|
|
539
|
+
tc_context = QTextCursor(doc)
|
|
540
|
+
tc_context.setPosition(0)
|
|
541
|
+
tc_context.setPosition(position, QTextCursor.MoveMode.KeepAnchor)
|
|
542
|
+
text_before_cursor = tc_context.selectedText().upper()
|
|
543
|
+
|
|
544
|
+
# Get the current line
|
|
545
|
+
tc.select(QTextCursor.SelectionType.LineUnderCursor)
|
|
546
|
+
current_line = tc.selectedText().strip().upper()
|
|
547
|
+
|
|
548
|
+
# Extract the last few keywords to determine context
|
|
549
|
+
words = re.findall(r'\b[A-Z_]+\b', text_before_cursor)
|
|
550
|
+
last_keywords = words[-3:] if words else []
|
|
551
|
+
|
|
552
|
+
# Get the current word being typed (including table prefixes)
|
|
553
|
+
current_word = self.get_word_under_cursor()
|
|
554
|
+
|
|
555
|
+
# Check for specific contexts
|
|
556
|
+
context = {
|
|
557
|
+
'type': 'unknown',
|
|
558
|
+
'table_prefix': None,
|
|
559
|
+
'after_from': False,
|
|
560
|
+
'after_join': False,
|
|
561
|
+
'after_select': False,
|
|
562
|
+
'after_where': False,
|
|
563
|
+
'after_group_by': False,
|
|
564
|
+
'after_order_by': False
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
# Check for table.column context
|
|
568
|
+
if '.' in current_word:
|
|
569
|
+
parts = current_word.split('.')
|
|
570
|
+
if len(parts) == 2:
|
|
571
|
+
context['type'] = 'column'
|
|
572
|
+
context['table_prefix'] = parts[0]
|
|
573
|
+
|
|
574
|
+
# FROM/JOIN context - likely to be followed by table names
|
|
575
|
+
if any(kw in last_keywords for kw in ['FROM', 'JOIN']):
|
|
576
|
+
context['type'] = 'table'
|
|
577
|
+
context['after_from'] = 'FROM' in last_keywords
|
|
578
|
+
context['after_join'] = any(k.endswith('JOIN') for k in last_keywords)
|
|
579
|
+
|
|
580
|
+
# WHERE/AND/OR context - likely to be followed by columns or expressions
|
|
581
|
+
elif any(kw in last_keywords for kw in ['WHERE', 'AND', 'OR']):
|
|
582
|
+
context['type'] = 'column_or_expression'
|
|
583
|
+
context['after_where'] = True
|
|
584
|
+
|
|
585
|
+
# SELECT context - likely to be followed by columns
|
|
586
|
+
elif 'SELECT' in last_keywords:
|
|
587
|
+
context['type'] = 'column'
|
|
588
|
+
context['after_select'] = True
|
|
589
|
+
|
|
590
|
+
# GROUP BY context
|
|
591
|
+
elif 'GROUP' in last_keywords or any('GROUP BY' in ' '.join(last_keywords[-2:]) for i in range(len(last_keywords)-1)):
|
|
592
|
+
context['type'] = 'column'
|
|
593
|
+
context['after_group_by'] = True
|
|
594
|
+
|
|
595
|
+
# ORDER BY context
|
|
596
|
+
elif 'ORDER' in last_keywords or any('ORDER BY' in ' '.join(last_keywords[-2:]) for i in range(len(last_keywords)-1)):
|
|
597
|
+
context['type'] = 'column'
|
|
598
|
+
context['after_order_by'] = True
|
|
599
|
+
|
|
600
|
+
# Check for function context (inside parentheses)
|
|
601
|
+
if '(' in text_before_cursor and text_before_cursor.count('(') > text_before_cursor.count(')'):
|
|
602
|
+
context['type'] = 'function_arg'
|
|
603
|
+
|
|
604
|
+
return context
|
|
605
|
+
|
|
606
|
+
def get_context_aware_completions(self, prefix):
|
|
607
|
+
"""Get completions based on the current context in the query"""
|
|
608
|
+
import time
|
|
609
|
+
|
|
610
|
+
# Don't waste time on empty prefixes or if we don't have tables
|
|
611
|
+
if not prefix and not self.tables_cache:
|
|
612
|
+
return self.all_sql_keywords
|
|
613
|
+
|
|
614
|
+
# Get context information
|
|
615
|
+
context = self.get_context_at_cursor()
|
|
616
|
+
|
|
617
|
+
# Default completions - all keywords and names
|
|
618
|
+
all_completions = []
|
|
619
|
+
|
|
620
|
+
# Add keywords appropriate for the current context
|
|
621
|
+
if context['type'] == 'table' or prefix.upper() in [k.upper() for k in self.all_sql_keywords]:
|
|
622
|
+
# After FROM/JOIN, prioritize table keywords
|
|
623
|
+
all_completions.extend(self.sql_keywords['basic'])
|
|
624
|
+
all_completions.extend(self.sql_keywords['table_ops'])
|
|
625
|
+
|
|
626
|
+
# Also include table names
|
|
627
|
+
all_completions.extend(self.tables_cache.keys())
|
|
628
|
+
|
|
629
|
+
elif context['type'] == 'column' and context['table_prefix']:
|
|
630
|
+
# For "table." completions, only show columns from that table
|
|
631
|
+
table = context['table_prefix']
|
|
632
|
+
if table in self.tables_cache:
|
|
633
|
+
all_completions.extend(self.tables_cache[table])
|
|
634
|
+
|
|
635
|
+
elif context['type'] == 'column' or context['type'] == 'column_or_expression':
|
|
636
|
+
# Add column-related keywords
|
|
637
|
+
all_completions.extend(self.sql_keywords['basic'])
|
|
638
|
+
all_completions.extend(self.sql_keywords['aggregation'])
|
|
639
|
+
all_completions.extend(self.sql_keywords['functions'])
|
|
640
|
+
|
|
641
|
+
# Add all columns from all tables
|
|
642
|
+
for table, columns in self.tables_cache.items():
|
|
643
|
+
all_completions.extend(columns)
|
|
644
|
+
# Also add qualified columns (table.column)
|
|
645
|
+
all_completions.extend([f"{table}.{col}" for col in columns])
|
|
646
|
+
|
|
647
|
+
elif context['type'] == 'function_arg':
|
|
648
|
+
# Inside a function, suggest columns
|
|
649
|
+
for columns in self.tables_cache.values():
|
|
650
|
+
all_completions.extend(columns)
|
|
651
|
+
|
|
652
|
+
else:
|
|
653
|
+
# Default case - include everything
|
|
654
|
+
all_completions.extend(self.all_sql_keywords)
|
|
655
|
+
|
|
656
|
+
# Add all table and column names
|
|
657
|
+
all_completions.extend(self.tables_cache.keys())
|
|
658
|
+
for columns in self.tables_cache.values():
|
|
659
|
+
all_completions.extend(columns)
|
|
660
|
+
|
|
661
|
+
# If the prefix looks like the start of a SQL statement or clause
|
|
662
|
+
if prefix and len(prefix) > 2 and prefix.isupper():
|
|
663
|
+
# Check each category for matching keywords
|
|
664
|
+
for category, keywords in self.sql_keywords.items():
|
|
665
|
+
for keyword in keywords:
|
|
666
|
+
if keyword.startswith(prefix):
|
|
667
|
+
all_completions.append(keyword)
|
|
668
|
+
|
|
669
|
+
# If the prefix looks like the start of a JOIN
|
|
670
|
+
if prefix and "JOIN" in prefix.upper():
|
|
671
|
+
all_completions.extend(self.sql_keywords['join'])
|
|
672
|
+
|
|
673
|
+
# Filter duplicates while preserving order
|
|
674
|
+
seen = set()
|
|
675
|
+
filtered_completions = []
|
|
676
|
+
for item in all_completions:
|
|
677
|
+
if item not in seen:
|
|
678
|
+
seen.add(item)
|
|
679
|
+
filtered_completions.append(item)
|
|
680
|
+
|
|
681
|
+
return filtered_completions
|
|
682
|
+
|
|
683
|
+
def complete(self):
|
|
684
|
+
"""Show ghost text completion instead of popup"""
|
|
685
|
+
import re
|
|
686
|
+
|
|
687
|
+
# Get the text under cursor
|
|
688
|
+
prefix = self.text_under_cursor()
|
|
689
|
+
current_word = self.get_word_under_cursor()
|
|
690
|
+
|
|
691
|
+
# Save current cursor position to detect if it changes during completion
|
|
692
|
+
initial_cursor_pos = self.textCursor().position()
|
|
693
|
+
|
|
694
|
+
# Clear existing ghost text first
|
|
695
|
+
self.clear_ghost_text()
|
|
696
|
+
|
|
697
|
+
# Don't show completion for empty text or too short prefixes unless it's a table prefix
|
|
698
|
+
is_table_prefix = '.' in current_word and current_word.endswith('.')
|
|
699
|
+
if not prefix and not is_table_prefix:
|
|
700
|
+
return
|
|
701
|
+
|
|
702
|
+
# Verify cursor hasn't moved (could happen if user is still typing rapidly)
|
|
703
|
+
if self.textCursor().position() != initial_cursor_pos:
|
|
704
|
+
return
|
|
705
|
+
|
|
706
|
+
# Get context-aware completions
|
|
707
|
+
completions = []
|
|
708
|
+
if self.tables_cache:
|
|
709
|
+
# Use our custom context-aware completion
|
|
710
|
+
completions = self.get_context_aware_completions(prefix)
|
|
711
|
+
|
|
712
|
+
# If no context-aware completions, fall back to basic model
|
|
713
|
+
if not completions and self.completer and self.completer.model():
|
|
714
|
+
model = self.completer.model()
|
|
715
|
+
for i in range(model.rowCount()):
|
|
716
|
+
completion = model.data(model.index(i, 0))
|
|
717
|
+
if completion and completion.lower().startswith(prefix.lower()):
|
|
718
|
+
completions.append(completion)
|
|
719
|
+
|
|
720
|
+
# Find the best suggestion
|
|
721
|
+
if completions:
|
|
722
|
+
# Sort by relevance - prioritize exact prefix matches and shorter suggestions
|
|
723
|
+
def relevance_score(item):
|
|
724
|
+
item_lower = item.lower()
|
|
725
|
+
prefix_lower = prefix.lower()
|
|
726
|
+
|
|
727
|
+
# Perfect case match gets highest priority
|
|
728
|
+
if item.startswith(prefix):
|
|
729
|
+
return (0, len(item))
|
|
730
|
+
# Case-insensitive prefix match
|
|
731
|
+
elif item_lower.startswith(prefix_lower):
|
|
732
|
+
return (1, len(item))
|
|
733
|
+
# Contains the prefix somewhere
|
|
734
|
+
elif prefix_lower in item_lower:
|
|
735
|
+
return (2, len(item))
|
|
736
|
+
else:
|
|
737
|
+
return (3, len(item))
|
|
738
|
+
|
|
739
|
+
completions.sort(key=relevance_score)
|
|
740
|
+
best_suggestion = completions[0]
|
|
741
|
+
|
|
742
|
+
# Final check: cursor hasn't moved
|
|
743
|
+
cursor_position = self.textCursor().position()
|
|
744
|
+
if cursor_position == initial_cursor_pos:
|
|
745
|
+
# Show ghost text for the best suggestion
|
|
746
|
+
self.show_ghost_text(best_suggestion, cursor_position)
|
|
747
|
+
|
|
748
|
+
def keyPressEvent(self, event):
|
|
749
|
+
# Check for Ctrl+Enter first, which should take precedence over other behaviors
|
|
750
|
+
if event.key() == Qt.Key.Key_Return and (event.modifiers() & Qt.KeyboardModifier.ControlModifier):
|
|
751
|
+
# Clear ghost text
|
|
752
|
+
self.clear_ghost_text()
|
|
753
|
+
|
|
754
|
+
# Cancel any pending autocomplete timers
|
|
755
|
+
if hasattr(self, '_completion_timer') and self._completion_timer.isActive():
|
|
756
|
+
self._completion_timer.stop()
|
|
757
|
+
|
|
758
|
+
# Let the main window handle query execution
|
|
759
|
+
event.accept() # Mark the event as handled
|
|
760
|
+
|
|
761
|
+
# Find the parent SQLShell instance and call its execute_query method
|
|
762
|
+
parent = self
|
|
763
|
+
while parent is not None:
|
|
764
|
+
if hasattr(parent, 'execute_query'):
|
|
765
|
+
parent.execute_query()
|
|
766
|
+
return
|
|
767
|
+
parent = parent.parent()
|
|
768
|
+
|
|
769
|
+
# If we couldn't find the execute_query method, pass the event up
|
|
770
|
+
super().keyPressEvent(event)
|
|
771
|
+
return
|
|
772
|
+
|
|
773
|
+
# Handle Tab key to accept ghost text
|
|
774
|
+
if event.key() == Qt.Key.Key_Tab:
|
|
775
|
+
# Only try to accept ghost text if cursor is near the position where ghost text was shown
|
|
776
|
+
# This prevents accidentally accepting ghost text when trying to indent on a new line
|
|
777
|
+
cursor_pos = self.textCursor().position()
|
|
778
|
+
# Allow cursor to have advanced a bit as user continues typing
|
|
779
|
+
position_diff = cursor_pos - self.ghost_text_position
|
|
780
|
+
if (self.ghost_text_suggestion and
|
|
781
|
+
self.ghost_text_position >= 0 and
|
|
782
|
+
position_diff >= 0 and position_diff <= 20 and # Allow up to 20 chars of continued typing
|
|
783
|
+
self.accept_ghost_text()):
|
|
784
|
+
return
|
|
785
|
+
else:
|
|
786
|
+
# Insert 4 spaces instead of a tab character if no ghost text at cursor
|
|
787
|
+
self.insertPlainText(" ")
|
|
788
|
+
return
|
|
789
|
+
|
|
790
|
+
# Clear ghost text on navigation keys
|
|
791
|
+
if event.key() in [Qt.Key.Key_Left, Qt.Key.Key_Right, Qt.Key.Key_Up, Qt.Key.Key_Down,
|
|
792
|
+
Qt.Key.Key_Home, Qt.Key.Key_End, Qt.Key.Key_PageUp, Qt.Key.Key_PageDown]:
|
|
793
|
+
self.clear_ghost_text()
|
|
794
|
+
super().keyPressEvent(event)
|
|
795
|
+
return
|
|
796
|
+
|
|
797
|
+
# Clear ghost text on Escape
|
|
798
|
+
if event.key() == Qt.Key.Key_Escape:
|
|
799
|
+
self.clear_ghost_text()
|
|
800
|
+
super().keyPressEvent(event)
|
|
801
|
+
return
|
|
802
|
+
|
|
803
|
+
# Clear ghost text on Enter/Return
|
|
804
|
+
if event.key() in [Qt.Key.Key_Enter, Qt.Key.Key_Return]:
|
|
805
|
+
self.clear_ghost_text()
|
|
806
|
+
|
|
807
|
+
# Auto-indentation for new lines
|
|
808
|
+
cursor = self.textCursor()
|
|
809
|
+
block = cursor.block()
|
|
810
|
+
text = block.text()
|
|
811
|
+
|
|
812
|
+
# Get the indentation of the current line
|
|
813
|
+
indentation = ""
|
|
814
|
+
for char in text:
|
|
815
|
+
if char.isspace():
|
|
816
|
+
indentation += char
|
|
817
|
+
else:
|
|
818
|
+
break
|
|
819
|
+
|
|
820
|
+
# Check if line ends with an opening bracket - only then increase indentation
|
|
821
|
+
increase_indent = ""
|
|
822
|
+
if text.strip().endswith("("):
|
|
823
|
+
increase_indent = " "
|
|
824
|
+
|
|
825
|
+
# Insert new line with proper indentation
|
|
826
|
+
super().keyPressEvent(event)
|
|
827
|
+
self.insertPlainText(indentation + increase_indent)
|
|
828
|
+
return
|
|
829
|
+
|
|
830
|
+
# Handle keyboard shortcuts
|
|
831
|
+
if event.modifiers() == Qt.KeyboardModifier.ControlModifier:
|
|
832
|
+
if event.key() == Qt.Key.Key_Space:
|
|
833
|
+
# Show completion manually
|
|
834
|
+
self.complete()
|
|
835
|
+
return
|
|
836
|
+
elif event.key() == Qt.Key.Key_K:
|
|
837
|
+
# Comment/uncomment the selected lines
|
|
838
|
+
self.toggle_comment()
|
|
839
|
+
return
|
|
840
|
+
elif event.key() == Qt.Key.Key_Slash:
|
|
841
|
+
# Also allow Ctrl+/ for commenting (common shortcut in other editors)
|
|
842
|
+
self.toggle_comment()
|
|
843
|
+
return
|
|
844
|
+
|
|
845
|
+
# Clear ghost text on space or punctuation
|
|
846
|
+
if event.text() and (event.text().isspace() or event.text() in ".,;()[]{}+-*/=<>!"):
|
|
847
|
+
self.clear_ghost_text()
|
|
848
|
+
|
|
849
|
+
# For normal key presses
|
|
850
|
+
super().keyPressEvent(event)
|
|
851
|
+
|
|
852
|
+
# Check for autocomplete after typing
|
|
853
|
+
if event.text() and not event.text().isspace():
|
|
854
|
+
# Only show completion if user is actively typing
|
|
855
|
+
# Add slight delay to avoid excessive completions
|
|
856
|
+
if hasattr(self, '_completion_timer'):
|
|
857
|
+
try:
|
|
858
|
+
if self._completion_timer.isActive():
|
|
859
|
+
self._completion_timer.stop()
|
|
860
|
+
except:
|
|
861
|
+
pass
|
|
862
|
+
|
|
863
|
+
# Create a timer to trigger completion after a short delay
|
|
864
|
+
self._completion_timer = QTimer()
|
|
865
|
+
self._completion_timer.setSingleShot(True)
|
|
866
|
+
self._completion_timer.timeout.connect(self.complete)
|
|
867
|
+
self._completion_timer.start(200) # 200 ms delay for ghost text (faster than popup)
|
|
868
|
+
|
|
869
|
+
elif event.key() == Qt.Key.Key_Backspace:
|
|
870
|
+
# Re-evaluate completion when backspacing, with a shorter delay
|
|
871
|
+
if hasattr(self, '_completion_timer'):
|
|
872
|
+
try:
|
|
873
|
+
if self._completion_timer.isActive():
|
|
874
|
+
self._completion_timer.stop()
|
|
875
|
+
except:
|
|
876
|
+
pass
|
|
877
|
+
|
|
878
|
+
self._completion_timer = QTimer()
|
|
879
|
+
self._completion_timer.setSingleShot(True)
|
|
880
|
+
self._completion_timer.timeout.connect(self.complete)
|
|
881
|
+
self._completion_timer.start(100) # 100 ms delay for backspace
|
|
882
|
+
|
|
883
|
+
else:
|
|
884
|
+
# Hide completion popup when inserting space or non-text characters
|
|
885
|
+
if self.completer and self.completer.popup().isVisible():
|
|
886
|
+
self.completer.popup().hide()
|
|
887
|
+
|
|
888
|
+
def paintEvent(self, event):
|
|
889
|
+
# Call the parent's paintEvent first
|
|
890
|
+
super().paintEvent(event)
|
|
891
|
+
|
|
892
|
+
# Get the current cursor
|
|
893
|
+
cursor = self.textCursor()
|
|
894
|
+
|
|
895
|
+
# If there's a selection, paint custom highlight
|
|
896
|
+
if cursor.hasSelection():
|
|
897
|
+
# Create a painter for this widget
|
|
898
|
+
painter = QPainter(self.viewport())
|
|
899
|
+
|
|
900
|
+
# Get the selection start and end positions
|
|
901
|
+
start = cursor.selectionStart()
|
|
902
|
+
end = cursor.selectionEnd()
|
|
903
|
+
|
|
904
|
+
# Create temporary cursor to get the rectangles
|
|
905
|
+
temp_cursor = QTextCursor(cursor)
|
|
906
|
+
|
|
907
|
+
# Move to start and get the starting position
|
|
908
|
+
temp_cursor.setPosition(start)
|
|
909
|
+
start_pos = self.cursorRect(temp_cursor)
|
|
910
|
+
|
|
911
|
+
# Move to end and get the ending position
|
|
912
|
+
temp_cursor.setPosition(end)
|
|
913
|
+
end_pos = self.cursorRect(temp_cursor)
|
|
914
|
+
|
|
915
|
+
# Set the highlight color with transparency
|
|
916
|
+
painter.setBrush(QBrush(self.selection_color))
|
|
917
|
+
painter.setPen(Qt.PenStyle.NoPen)
|
|
918
|
+
|
|
919
|
+
# Draw the highlight rectangle
|
|
920
|
+
if start_pos.top() == end_pos.top():
|
|
921
|
+
# Single line selection
|
|
922
|
+
painter.drawRect(QRect(start_pos.left(), start_pos.top(),
|
|
923
|
+
end_pos.right() - start_pos.left(), start_pos.height()))
|
|
924
|
+
else:
|
|
925
|
+
# Multi-line selection
|
|
926
|
+
# First line
|
|
927
|
+
painter.drawRect(QRect(start_pos.left(), start_pos.top(),
|
|
928
|
+
self.viewport().width() - start_pos.left(), start_pos.height()))
|
|
929
|
+
|
|
930
|
+
# Middle lines (if any)
|
|
931
|
+
if end_pos.top() > start_pos.top() + start_pos.height():
|
|
932
|
+
painter.drawRect(QRect(0, start_pos.top() + start_pos.height(),
|
|
933
|
+
self.viewport().width(),
|
|
934
|
+
end_pos.top() - (start_pos.top() + start_pos.height())))
|
|
935
|
+
|
|
936
|
+
# Last line
|
|
937
|
+
painter.drawRect(QRect(0, end_pos.top(), end_pos.right(), end_pos.height()))
|
|
938
|
+
|
|
939
|
+
painter.end()
|
|
940
|
+
|
|
941
|
+
# Render ghost text if available
|
|
942
|
+
if self.ghost_text and not cursor.hasSelection():
|
|
943
|
+
painter = QPainter(self.viewport())
|
|
944
|
+
|
|
945
|
+
try:
|
|
946
|
+
# Enable anti-aliasing for smoother ghost text rendering
|
|
947
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
|
948
|
+
painter.setRenderHint(QPainter.RenderHint.TextAntialiasing, True)
|
|
949
|
+
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, True)
|
|
950
|
+
|
|
951
|
+
# Get current cursor position
|
|
952
|
+
cursor_rect = self.cursorRect()
|
|
953
|
+
|
|
954
|
+
# Set ghost text color and font with italic style for differentiation
|
|
955
|
+
painter.setPen(self.ghost_text_color)
|
|
956
|
+
font = self.font()
|
|
957
|
+
font.setItalic(True) # Italic makes ghost text more distinguishable
|
|
958
|
+
painter.setFont(font)
|
|
959
|
+
|
|
960
|
+
# Calculate position for ghost text (right after cursor)
|
|
961
|
+
x = cursor_rect.right()
|
|
962
|
+
y = cursor_rect.top()
|
|
963
|
+
|
|
964
|
+
# Ensure we don't draw outside the viewport
|
|
965
|
+
if x >= 0 and y >= 0 and x < self.viewport().width() and y < self.viewport().height():
|
|
966
|
+
# Draw the ghost text with high-quality rendering
|
|
967
|
+
painter.drawText(x, y + self.fontMetrics().ascent(), self.ghost_text)
|
|
968
|
+
|
|
969
|
+
except Exception as e:
|
|
970
|
+
# If there's any error with rendering, just skip it
|
|
971
|
+
print(f"Error rendering ghost text: {e}")
|
|
972
|
+
finally:
|
|
973
|
+
painter.end()
|
|
974
|
+
|
|
975
|
+
def focusInEvent(self, event):
|
|
976
|
+
super().focusInEvent(event)
|
|
977
|
+
# Show temporary hint in status bar when editor gets focus
|
|
978
|
+
if hasattr(self.parent(), 'statusBar'):
|
|
979
|
+
self.parent().parent().parent().statusBar().showMessage('Ghost text autocomplete: Press Tab to accept suggestions | Ctrl+Space for manual completion', 3000)
|
|
980
|
+
|
|
981
|
+
def toggle_comment(self):
|
|
982
|
+
cursor = self.textCursor()
|
|
983
|
+
if cursor.hasSelection():
|
|
984
|
+
# Get the selected text
|
|
985
|
+
start = cursor.selectionStart()
|
|
986
|
+
end = cursor.selectionEnd()
|
|
987
|
+
|
|
988
|
+
# Remember the selection
|
|
989
|
+
cursor.setPosition(start)
|
|
990
|
+
start_block = cursor.blockNumber()
|
|
991
|
+
cursor.setPosition(end)
|
|
992
|
+
end_block = cursor.blockNumber()
|
|
993
|
+
|
|
994
|
+
# Process each line in the selection
|
|
995
|
+
cursor.setPosition(start)
|
|
996
|
+
cursor.beginEditBlock()
|
|
997
|
+
|
|
998
|
+
for _ in range(start_block, end_block + 1):
|
|
999
|
+
# Move to start of line
|
|
1000
|
+
cursor.movePosition(cursor.MoveOperation.StartOfLine)
|
|
1001
|
+
|
|
1002
|
+
# Check if the line is already commented
|
|
1003
|
+
line_text = cursor.block().text().lstrip()
|
|
1004
|
+
if line_text.startswith('--'):
|
|
1005
|
+
# Remove comment
|
|
1006
|
+
pos = cursor.block().text().find('--')
|
|
1007
|
+
cursor.setPosition(cursor.block().position() + pos)
|
|
1008
|
+
cursor.deleteChar()
|
|
1009
|
+
cursor.deleteChar()
|
|
1010
|
+
else:
|
|
1011
|
+
# Add comment
|
|
1012
|
+
cursor.insertText('--')
|
|
1013
|
+
|
|
1014
|
+
# Move to next line if not at the end
|
|
1015
|
+
if not cursor.atEnd():
|
|
1016
|
+
cursor.movePosition(cursor.MoveOperation.NextBlock)
|
|
1017
|
+
|
|
1018
|
+
cursor.endEditBlock()
|
|
1019
|
+
else:
|
|
1020
|
+
# Comment/uncomment current line
|
|
1021
|
+
cursor.movePosition(cursor.MoveOperation.StartOfLine)
|
|
1022
|
+
cursor.movePosition(cursor.MoveOperation.EndOfLine, cursor.MoveMode.KeepAnchor)
|
|
1023
|
+
line_text = cursor.selectedText().lstrip()
|
|
1024
|
+
|
|
1025
|
+
cursor.movePosition(cursor.MoveOperation.StartOfLine)
|
|
1026
|
+
if line_text.startswith('--'):
|
|
1027
|
+
# Remove comment
|
|
1028
|
+
pos = cursor.block().text().find('--')
|
|
1029
|
+
cursor.setPosition(cursor.block().position() + pos)
|
|
1030
|
+
cursor.deleteChar()
|
|
1031
|
+
cursor.deleteChar()
|
|
1032
|
+
else:
|
|
1033
|
+
# Add comment
|
|
1034
|
+
cursor.insertText('--')
|
|
1035
|
+
|
|
1036
|
+
def line_number_area_width(self):
|
|
1037
|
+
digits = 1
|
|
1038
|
+
max_num = max(1, self.blockCount())
|
|
1039
|
+
while max_num >= 10:
|
|
1040
|
+
max_num //= 10
|
|
1041
|
+
digits += 1
|
|
1042
|
+
|
|
1043
|
+
space = 3 + self.fontMetrics().horizontalAdvance('9') * digits
|
|
1044
|
+
return space
|
|
1045
|
+
|
|
1046
|
+
def update_line_number_area_width(self, _):
|
|
1047
|
+
self.setViewportMargins(self.line_number_area_width(), 0, 0, 0)
|
|
1048
|
+
|
|
1049
|
+
def update_line_number_area(self, rect, dy):
|
|
1050
|
+
if dy:
|
|
1051
|
+
self.line_number_area.scroll(0, dy)
|
|
1052
|
+
else:
|
|
1053
|
+
self.line_number_area.update(0, rect.y(), self.line_number_area.width(), rect.height())
|
|
1054
|
+
|
|
1055
|
+
if rect.contains(self.viewport().rect()):
|
|
1056
|
+
self.update_line_number_area_width(0)
|
|
1057
|
+
|
|
1058
|
+
def resizeEvent(self, event):
|
|
1059
|
+
super().resizeEvent(event)
|
|
1060
|
+
cr = self.contentsRect()
|
|
1061
|
+
self.line_number_area.setGeometry(QRect(cr.left(), cr.top(), self.line_number_area_width(), cr.height()))
|
|
1062
|
+
|
|
1063
|
+
def line_number_area_paint_event(self, event):
|
|
1064
|
+
"""Paint line numbers with improved styling and readability."""
|
|
1065
|
+
painter = QPainter(self.line_number_area)
|
|
1066
|
+
|
|
1067
|
+
# Modern subtle background color (slightly darker than white)
|
|
1068
|
+
painter.fillRect(event.rect(), QColor("#F6F8FA")) # GitHub-style gutter color
|
|
1069
|
+
|
|
1070
|
+
# Enable anti-aliasing for crisp text
|
|
1071
|
+
painter.setRenderHint(QPainter.RenderHint.TextAntialiasing, True)
|
|
1072
|
+
|
|
1073
|
+
block = self.firstVisibleBlock()
|
|
1074
|
+
block_number = block.blockNumber()
|
|
1075
|
+
top = round(self.blockBoundingGeometry(block).translated(self.contentOffset()).top())
|
|
1076
|
+
bottom = top + round(self.blockBoundingRect(block).height())
|
|
1077
|
+
|
|
1078
|
+
while block.isValid() and top <= event.rect().bottom():
|
|
1079
|
+
if block.isVisible() and bottom >= event.rect().top():
|
|
1080
|
+
number = str(block_number + 1)
|
|
1081
|
+
|
|
1082
|
+
# Use a subtle gray that maintains readability (WCAG AA compliant)
|
|
1083
|
+
# Current line gets slightly darker color for emphasis
|
|
1084
|
+
current_block = self.textCursor().block()
|
|
1085
|
+
if block == current_block:
|
|
1086
|
+
painter.setPen(QColor("#24292F")) # Darker for current line
|
|
1087
|
+
# Optional: add subtle background highlight for current line
|
|
1088
|
+
painter.fillRect(0, top, self.line_number_area.width(),
|
|
1089
|
+
self.fontMetrics().height(), QColor("#E8EDF2"))
|
|
1090
|
+
else:
|
|
1091
|
+
painter.setPen(QColor("#57606A")) # Subtle gray for other lines
|
|
1092
|
+
|
|
1093
|
+
# Draw line number with right alignment and padding
|
|
1094
|
+
painter.drawText(0, top, self.line_number_area.width() - 8,
|
|
1095
|
+
self.fontMetrics().height(),
|
|
1096
|
+
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
|
|
1097
|
+
number)
|
|
1098
|
+
|
|
1099
|
+
block = block.next()
|
|
1100
|
+
top = bottom
|
|
1101
|
+
bottom = top + round(self.blockBoundingRect(block).height())
|
|
1102
|
+
block_number += 1
|
|
1103
|
+
|
|
1104
|
+
def dragEnterEvent(self, event):
|
|
1105
|
+
"""Handle drag enter events to allow dropping table names."""
|
|
1106
|
+
# Accept text/plain mime data (used for table names)
|
|
1107
|
+
if event.mimeData().hasText():
|
|
1108
|
+
event.acceptProposedAction()
|
|
1109
|
+
else:
|
|
1110
|
+
event.ignore()
|
|
1111
|
+
|
|
1112
|
+
def dragMoveEvent(self, event):
|
|
1113
|
+
"""Handle drag move events to show valid drop locations."""
|
|
1114
|
+
if event.mimeData().hasText():
|
|
1115
|
+
event.acceptProposedAction()
|
|
1116
|
+
else:
|
|
1117
|
+
event.ignore()
|
|
1118
|
+
|
|
1119
|
+
def dropEvent(self, event):
|
|
1120
|
+
"""Handle drop event to insert table name at cursor position."""
|
|
1121
|
+
if event.mimeData().hasText():
|
|
1122
|
+
# Get table name from dropped text
|
|
1123
|
+
text = event.mimeData().text()
|
|
1124
|
+
|
|
1125
|
+
# Try to extract table name from custom mime data if available
|
|
1126
|
+
if event.mimeData().hasFormat('application/x-sqlshell-tablename'):
|
|
1127
|
+
table_name = bytes(event.mimeData().data('application/x-sqlshell-tablename')).decode()
|
|
1128
|
+
else:
|
|
1129
|
+
# Extract actual table name (if it includes parentheses)
|
|
1130
|
+
if " (" in text:
|
|
1131
|
+
table_name = text.split(" (")[0]
|
|
1132
|
+
else:
|
|
1133
|
+
table_name = text
|
|
1134
|
+
|
|
1135
|
+
# Get current cursor position and surrounding text
|
|
1136
|
+
cursor = self.textCursor()
|
|
1137
|
+
document = self.document()
|
|
1138
|
+
current_block = cursor.block()
|
|
1139
|
+
block_text = current_block.text()
|
|
1140
|
+
position_in_block = cursor.positionInBlock()
|
|
1141
|
+
|
|
1142
|
+
# Get text before cursor in current line
|
|
1143
|
+
text_before = block_text[:position_in_block].strip().upper()
|
|
1144
|
+
|
|
1145
|
+
# Determine how to insert the table name based on context
|
|
1146
|
+
if (text_before.endswith("FROM") or
|
|
1147
|
+
text_before.endswith("JOIN") or
|
|
1148
|
+
text_before.endswith("INTO") or
|
|
1149
|
+
text_before.endswith("UPDATE") or
|
|
1150
|
+
text_before.endswith(",")):
|
|
1151
|
+
# Just insert the table name with a space before it
|
|
1152
|
+
cursor.insertText(f" {table_name}")
|
|
1153
|
+
elif text_before.endswith("FROM ") or text_before.endswith("JOIN ") or text_before.endswith("INTO ") or text_before.endswith(", "):
|
|
1154
|
+
# Just insert the table name without a space
|
|
1155
|
+
cursor.insertText(table_name)
|
|
1156
|
+
elif not text_before and not block_text:
|
|
1157
|
+
# If at empty line, insert a SELECT statement
|
|
1158
|
+
cursor.insertText(f"SELECT * FROM {table_name}")
|
|
1159
|
+
else:
|
|
1160
|
+
# Default: just insert the table name at cursor position
|
|
1161
|
+
cursor.insertText(table_name)
|
|
1162
|
+
|
|
1163
|
+
# Accept the action
|
|
1164
|
+
event.acceptProposedAction()
|
|
1165
|
+
else:
|
|
1166
|
+
event.ignore()
|