sqlshell 0.1.6__py3-none-any.whl → 0.1.9__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.

Potentially problematic release.


This version of sqlshell might be problematic. Click here for more details.

sqlshell/editor.py ADDED
@@ -0,0 +1,856 @@
1
+ from PyQt6.QtWidgets import QPlainTextEdit, QWidget, QCompleter
2
+ from PyQt6.QtCore import Qt, QSize, QRect, QStringListModel, QTimer
3
+ from PyQt6.QtGui import QFont, QColor, QTextCursor, QPainter, QBrush
4
+ import re
5
+
6
+ class LineNumberArea(QWidget):
7
+ def __init__(self, editor):
8
+ super().__init__(editor)
9
+ self.editor = editor
10
+
11
+ def sizeHint(self):
12
+ return QSize(self.editor.line_number_area_width(), 0)
13
+
14
+ def paintEvent(self, event):
15
+ self.editor.line_number_area_paint_event(event)
16
+
17
+ class SQLEditor(QPlainTextEdit):
18
+ def __init__(self, parent=None):
19
+ super().__init__(parent)
20
+ self.line_number_area = LineNumberArea(self)
21
+
22
+ # Set monospaced font
23
+ font = QFont("Consolas", 12) # Increased font size for better readability
24
+ font.setFixedPitch(True)
25
+ self.setFont(font)
26
+
27
+ # Connect signals
28
+ self.blockCountChanged.connect(self.update_line_number_area_width)
29
+ self.updateRequest.connect(self.update_line_number_area)
30
+
31
+ # Initialize
32
+ self.update_line_number_area_width(0)
33
+
34
+ # Set tab width to 4 spaces
35
+ self.setTabStopDistance(4 * self.fontMetrics().horizontalAdvance(' '))
36
+
37
+ # Set placeholder text
38
+ self.setPlaceholderText("Enter your SQL query here...")
39
+
40
+ # Set modern selection color
41
+ self.selection_color = QColor("#3498DB")
42
+ self.selection_color.setAlpha(50) # Make it semi-transparent
43
+
44
+ # SQL keywords for syntax highlighting and autocompletion
45
+ self.sql_keywords = {
46
+ 'basic': [
47
+ "SELECT", "FROM", "WHERE", "INSERT", "UPDATE", "DELETE", "CREATE", "DROP",
48
+ "ALTER", "TABLE", "VIEW", "INDEX", "TRIGGER", "PROCEDURE", "FUNCTION",
49
+ "AS", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "IS NULL", "IS NOT NULL",
50
+ "ORDER BY", "GROUP BY", "HAVING", "LIMIT", "OFFSET", "TOP", "DISTINCT",
51
+ "ON", "SET", "VALUES", "INTO", "DEFAULT", "PRIMARY KEY", "FOREIGN KEY",
52
+ "JOIN", "LEFT JOIN", "RIGHT JOIN", "INNER JOIN", "FULL JOIN",
53
+ "CASE", "WHEN", "THEN", "ELSE", "END", "IF", "BEGIN", "END", "COMMIT",
54
+ "ROLLBACK"
55
+ ],
56
+ 'aggregation': [
57
+ "SUM", "AVG", "COUNT", "MIN", "MAX", "STDDEV", "VARIANCE", "FIRST",
58
+ "LAST", "GROUP_CONCAT"
59
+ ],
60
+ 'join': [
61
+ "INNER JOIN", "LEFT JOIN", "RIGHT JOIN", "FULL JOIN", "CROSS JOIN",
62
+ "JOIN ... ON", "JOIN ... USING", "NATURAL JOIN"
63
+ ],
64
+ 'functions': [
65
+ "SUBSTR", "SUBSTRING", "UPPER", "LOWER", "TRIM", "LTRIM", "RTRIM",
66
+ "LENGTH", "CONCAT", "REPLACE", "INSTR", "CAST", "CONVERT", "COALESCE",
67
+ "NULLIF", "NVL", "IFNULL", "DECODE", "ROUND", "TRUNC", "FLOOR", "CEILING",
68
+ "ABS", "MOD", "DATE", "TIME", "DATETIME", "TIMESTAMP", "EXTRACT", "DATEADD",
69
+ "DATEDIFF", "CURRENT_DATE", "CURRENT_TIME", "CURRENT_TIMESTAMP"
70
+ ],
71
+ 'table_ops': [
72
+ "INSERT INTO", "UPDATE", "DELETE FROM", "CREATE TABLE", "DROP TABLE",
73
+ "ALTER TABLE", "ADD COLUMN", "DROP COLUMN", "MODIFY COLUMN", "RENAME TO",
74
+ "TRUNCATE TABLE", "VACUUM"
75
+ ],
76
+ 'types': [
77
+ "INTEGER", "INT", "BIGINT", "SMALLINT", "TINYINT", "NUMERIC",
78
+ "DECIMAL", "FLOAT", "REAL", "DOUBLE", "BOOLEAN", "CHAR",
79
+ "VARCHAR", "TEXT", "DATE", "TIME", "TIMESTAMP", "INTERVAL",
80
+ "UUID", "JSON", "JSONB", "ARRAY", "BLOB"
81
+ ],
82
+ 'window': [
83
+ "OVER (", "PARTITION BY", "ORDER BY", "ROWS BETWEEN", "RANGE BETWEEN",
84
+ "UNBOUNDED PRECEDING", "CURRENT ROW", "UNBOUNDED FOLLOWING",
85
+ "ROW_NUMBER()", "RANK()", "DENSE_RANK()", "LEAD(", "LAG("
86
+ ],
87
+ 'other': [
88
+ "WITH", "UNION", "UNION ALL", "INTERSECT", "EXCEPT", "DISTINCT",
89
+ "ALL", "ANY", "SOME", "RECURSIVE", "GROUPING SETS", "CUBE", "ROLLUP"
90
+ ]
91
+ }
92
+
93
+ # Flattened list of all SQL keywords
94
+ self.all_sql_keywords = []
95
+ for category in self.sql_keywords.values():
96
+ self.all_sql_keywords.extend(category)
97
+
98
+ # Common SQL patterns with placeholders
99
+ self.sql_patterns = [
100
+ "SELECT * FROM $table WHERE $column = $value",
101
+ "SELECT $columns FROM $table GROUP BY $column HAVING $condition",
102
+ "SELECT $columns FROM $table ORDER BY $column $direction LIMIT $limit",
103
+ "SELECT $table1.$column1, $table2.$column2 FROM $table1 JOIN $table2 ON $table1.$column = $table2.$column",
104
+ "INSERT INTO $table ($columns) VALUES ($values)",
105
+ "UPDATE $table SET $column = $value WHERE $condition",
106
+ "DELETE FROM $table WHERE $condition",
107
+ "WITH $cte AS (SELECT * FROM $table) SELECT * FROM $cte WHERE $condition"
108
+ ]
109
+
110
+ # Initialize completer with SQL keywords
111
+ self.completer = None
112
+ self.set_completer(QCompleter(self.all_sql_keywords))
113
+
114
+ # Track last key press for better completion behavior
115
+ self.last_key_was_tab = False
116
+
117
+ # Tables and columns cache for context-aware completion
118
+ self.tables_cache = {}
119
+ self.last_update_time = 0
120
+
121
+ # Enable drag and drop
122
+ self.setAcceptDrops(True)
123
+
124
+ def set_completer(self, completer):
125
+ """Set the completer for the editor"""
126
+ if self.completer:
127
+ try:
128
+ self.completer.disconnect(self)
129
+ except Exception:
130
+ pass # Ignore errors when disconnecting
131
+
132
+ self.completer = completer
133
+
134
+ if not self.completer:
135
+ return
136
+
137
+ self.completer.setWidget(self)
138
+ self.completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
139
+ self.completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
140
+ self.completer.activated.connect(self.insert_completion)
141
+
142
+ def update_completer_model(self, words_or_model):
143
+ """Update the completer model with new words or a new model
144
+
145
+ Args:
146
+ words_or_model: Either a list of words or a QStringListModel
147
+ """
148
+ if not self.completer:
149
+ # Create a completer if none exists
150
+ self.set_completer(QCompleter(self.all_sql_keywords))
151
+ if not self.completer:
152
+ return
153
+
154
+ # If a model is passed directly, use it
155
+ if isinstance(words_or_model, QStringListModel):
156
+ try:
157
+ # Update our tables and columns cache for context-aware completion
158
+ words = words_or_model.stringList()
159
+ self._update_tables_cache(words)
160
+ self.completer.setModel(words_or_model)
161
+ except Exception as e:
162
+ # If there's an error, fall back to just SQL keywords
163
+ model = QStringListModel()
164
+ model.setStringList(self.all_sql_keywords)
165
+ self.completer.setModel(model)
166
+ print(f"Error updating completer model: {e}")
167
+
168
+ return
169
+
170
+ try:
171
+ # Update tables cache
172
+ self._update_tables_cache(words_or_model)
173
+
174
+ # Otherwise, combine SQL keywords with table/column names and create a new model
175
+ # Use set operations for efficiency
176
+ words_set = set(words_or_model) # Remove duplicates
177
+ sql_keywords_set = set(self.all_sql_keywords)
178
+ all_words = list(sql_keywords_set.union(words_set))
179
+
180
+ # Sort the combined words for better autocomplete experience
181
+ all_words.sort(key=lambda x: (not x.isupper(), x)) # Prioritize SQL keywords (all uppercase)
182
+
183
+ # Create an optimized model with all words
184
+ model = QStringListModel()
185
+ model.setStringList(all_words)
186
+
187
+ # Set the model to the completer
188
+ self.completer.setModel(model)
189
+ except Exception as e:
190
+ # If there's an error, fall back to just SQL keywords
191
+ model = QStringListModel()
192
+ model.setStringList(self.all_sql_keywords)
193
+ self.completer.setModel(model)
194
+ print(f"Error updating completer with words: {e}")
195
+
196
+ def _update_tables_cache(self, words):
197
+ """Update internal tables and columns cache from word list"""
198
+ self.tables_cache = {}
199
+
200
+ # Create a map of tables to columns
201
+ for word in words:
202
+ if '.' in word:
203
+ # This is a qualified column (table.column)
204
+ parts = word.split('.')
205
+ if len(parts) == 2:
206
+ table, column = parts
207
+ if table not in self.tables_cache:
208
+ self.tables_cache[table] = []
209
+ if column not in self.tables_cache[table]:
210
+ self.tables_cache[table].append(column)
211
+ else:
212
+ # Could be a table or a standalone column
213
+ # We'll assume tables as being words that don't have special characters
214
+ if not any(c in word for c in ',;()[]+-*/=<>%|&!?:'):
215
+ # Add as potential table
216
+ if word not in self.tables_cache:
217
+ self.tables_cache[word] = []
218
+
219
+ def get_word_under_cursor(self):
220
+ """Get the complete word under the cursor for completion, handling dot notation"""
221
+ tc = self.textCursor()
222
+ current_position = tc.position()
223
+
224
+ # Get the current line of text
225
+ tc.select(QTextCursor.SelectionType.LineUnderCursor)
226
+ line_text = tc.selectedText()
227
+
228
+ # Calculate cursor position within the line
229
+ start_of_line_pos = current_position - tc.selectionStart()
230
+
231
+ # Identify word boundaries including dots
232
+ start_pos = start_of_line_pos
233
+ while start_pos > 0 and (line_text[start_pos-1].isalnum() or line_text[start_pos-1] in '_$.'):
234
+ start_pos -= 1
235
+
236
+ end_pos = start_of_line_pos
237
+ while end_pos < len(line_text) and (line_text[end_pos].isalnum() or line_text[end_pos] in '_$'):
238
+ end_pos += 1
239
+
240
+ if start_pos == end_pos:
241
+ return ""
242
+
243
+ word = line_text[start_pos:end_pos]
244
+ return word
245
+
246
+ def text_under_cursor(self):
247
+ """Get the text under cursor for standard completion behavior"""
248
+ # Get the complete word including table prefixes
249
+ word = self.get_word_under_cursor()
250
+
251
+ # For table.col completions, only return portion after the dot
252
+ if '.' in word and word.endswith('.'):
253
+ # For "table." return empty to trigger whole column list
254
+ return ""
255
+ elif '.' in word:
256
+ # For "table.co", return "co" for completion
257
+ return word.split('.')[-1]
258
+
259
+ # Otherwise return the whole word
260
+ return word
261
+
262
+ def insert_completion(self, completion):
263
+ """Insert the completion text with enhanced context awareness"""
264
+ if self.completer.widget() != self:
265
+ return
266
+
267
+ tc = self.textCursor()
268
+
269
+ # Handle table.column completion differently
270
+ word = self.get_word_under_cursor()
271
+ if '.' in word and not word.endswith('.'):
272
+ # We're completing something like "table.co" to "table.column"
273
+ # Replace only the part after the last dot
274
+ prefix_parts = word.split('.')
275
+ prefix = '.'.join(prefix_parts[:-1]) + '.'
276
+ suffix = prefix_parts[-1]
277
+
278
+ # Get positions for text manipulation
279
+ cursor_pos = tc.position()
280
+ tc.setPosition(cursor_pos - len(suffix))
281
+ tc.setPosition(cursor_pos, QTextCursor.MoveMode.KeepAnchor)
282
+ tc.removeSelectedText()
283
+ tc.insertText(completion)
284
+ else:
285
+ # Standard completion behavior
286
+ current_prefix = self.completer.completionPrefix()
287
+
288
+ # When completing, replace the entire prefix with the completion
289
+ # This ensures exact matches are handled correctly
290
+ if current_prefix:
291
+ # Get positions for text manipulation
292
+ cursor_pos = tc.position()
293
+ tc.setPosition(cursor_pos - len(current_prefix))
294
+ tc.setPosition(cursor_pos, QTextCursor.MoveMode.KeepAnchor)
295
+ tc.removeSelectedText()
296
+
297
+ # Don't automatically add space when completing with Tab
298
+ # or when completion already ends with special characters
299
+ special_endings = ["(", ")", ",", ";", ".", "*"]
300
+ if any(completion.endswith(char) for char in special_endings):
301
+ tc.insertText(completion)
302
+ else:
303
+ # Add space for normal words, but only if activated with Enter/Return
304
+ # not when using Tab for completion
305
+ from_keyboard = self.sender() is None
306
+ add_space = from_keyboard or not self.last_key_was_tab
307
+ tc.insertText(completion + (" " if add_space else ""))
308
+
309
+ self.setTextCursor(tc)
310
+
311
+ def get_context_at_cursor(self):
312
+ """Analyze the query to determine the current SQL context for smarter completions"""
313
+ # Get text up to cursor to analyze context
314
+ tc = self.textCursor()
315
+ position = tc.position()
316
+
317
+ # Select all text from start to cursor
318
+ doc = self.document()
319
+ tc_context = QTextCursor(doc)
320
+ tc_context.setPosition(0)
321
+ tc_context.setPosition(position, QTextCursor.MoveMode.KeepAnchor)
322
+ text_before_cursor = tc_context.selectedText().upper()
323
+
324
+ # Get the current line
325
+ tc.select(QTextCursor.SelectionType.LineUnderCursor)
326
+ current_line = tc.selectedText().strip().upper()
327
+
328
+ # Extract the last few keywords to determine context
329
+ words = re.findall(r'\b[A-Z_]+\b', text_before_cursor)
330
+ last_keywords = words[-3:] if words else []
331
+
332
+ # Get the current word being typed (including table prefixes)
333
+ current_word = self.get_word_under_cursor()
334
+
335
+ # Check for specific contexts
336
+ context = {
337
+ 'type': 'unknown',
338
+ 'table_prefix': None,
339
+ 'after_from': False,
340
+ 'after_join': False,
341
+ 'after_select': False,
342
+ 'after_where': False,
343
+ 'after_group_by': False,
344
+ 'after_order_by': False
345
+ }
346
+
347
+ # Check for table.column context
348
+ if '.' in current_word:
349
+ parts = current_word.split('.')
350
+ if len(parts) == 2:
351
+ context['type'] = 'column'
352
+ context['table_prefix'] = parts[0]
353
+
354
+ # FROM/JOIN context - likely to be followed by table names
355
+ if any(kw in last_keywords for kw in ['FROM', 'JOIN']):
356
+ context['type'] = 'table'
357
+ context['after_from'] = 'FROM' in last_keywords
358
+ context['after_join'] = any(k.endswith('JOIN') for k in last_keywords)
359
+
360
+ # WHERE/AND/OR context - likely to be followed by columns or expressions
361
+ elif any(kw in last_keywords for kw in ['WHERE', 'AND', 'OR']):
362
+ context['type'] = 'column_or_expression'
363
+ context['after_where'] = True
364
+
365
+ # SELECT context - likely to be followed by columns
366
+ elif 'SELECT' in last_keywords:
367
+ context['type'] = 'column'
368
+ context['after_select'] = True
369
+
370
+ # GROUP BY context
371
+ elif 'GROUP' in last_keywords or any('GROUP BY' in ' '.join(last_keywords[-2:]) for i in range(len(last_keywords)-1)):
372
+ context['type'] = 'column'
373
+ context['after_group_by'] = True
374
+
375
+ # ORDER BY context
376
+ elif 'ORDER' in last_keywords or any('ORDER BY' in ' '.join(last_keywords[-2:]) for i in range(len(last_keywords)-1)):
377
+ context['type'] = 'column'
378
+ context['after_order_by'] = True
379
+
380
+ # Check for function context (inside parentheses)
381
+ if '(' in text_before_cursor and text_before_cursor.count('(') > text_before_cursor.count(')'):
382
+ context['type'] = 'function_arg'
383
+
384
+ return context
385
+
386
+ def get_context_aware_completions(self, prefix):
387
+ """Get completions based on the current context in the query"""
388
+ import time
389
+
390
+ # Don't waste time on empty prefixes or if we don't have tables
391
+ if not prefix and not self.tables_cache:
392
+ return self.all_sql_keywords
393
+
394
+ # Get context information
395
+ context = self.get_context_at_cursor()
396
+
397
+ # Default completions - all keywords and names
398
+ all_completions = []
399
+
400
+ # Add keywords appropriate for the current context
401
+ if context['type'] == 'table' or prefix.upper() in [k.upper() for k in self.all_sql_keywords]:
402
+ # After FROM/JOIN, prioritize table keywords
403
+ all_completions.extend(self.sql_keywords['basic'])
404
+ all_completions.extend(self.sql_keywords['table_ops'])
405
+
406
+ # Also include table names
407
+ all_completions.extend(self.tables_cache.keys())
408
+
409
+ elif context['type'] == 'column' and context['table_prefix']:
410
+ # For "table." completions, only show columns from that table
411
+ table = context['table_prefix']
412
+ if table in self.tables_cache:
413
+ all_completions.extend(self.tables_cache[table])
414
+
415
+ elif context['type'] == 'column' or context['type'] == 'column_or_expression':
416
+ # Add column-related keywords
417
+ all_completions.extend(self.sql_keywords['basic'])
418
+ all_completions.extend(self.sql_keywords['aggregation'])
419
+ all_completions.extend(self.sql_keywords['functions'])
420
+
421
+ # Add all columns from all tables
422
+ for table, columns in self.tables_cache.items():
423
+ all_completions.extend(columns)
424
+ # Also add qualified columns (table.column)
425
+ all_completions.extend([f"{table}.{col}" for col in columns])
426
+
427
+ elif context['type'] == 'function_arg':
428
+ # Inside a function, suggest columns
429
+ for columns in self.tables_cache.values():
430
+ all_completions.extend(columns)
431
+
432
+ else:
433
+ # Default case - include everything
434
+ all_completions.extend(self.all_sql_keywords)
435
+
436
+ # Add all table and column names
437
+ all_completions.extend(self.tables_cache.keys())
438
+ for columns in self.tables_cache.values():
439
+ all_completions.extend(columns)
440
+
441
+ # If the prefix looks like the start of a SQL statement or clause
442
+ if prefix and len(prefix) > 2 and prefix.isupper():
443
+ # Check each category for matching keywords
444
+ for category, keywords in self.sql_keywords.items():
445
+ for keyword in keywords:
446
+ if keyword.startswith(prefix):
447
+ all_completions.append(keyword)
448
+
449
+ # If the prefix looks like the start of a JOIN
450
+ if prefix and "JOIN" in prefix.upper():
451
+ all_completions.extend(self.sql_keywords['join'])
452
+
453
+ # Filter duplicates while preserving order
454
+ seen = set()
455
+ filtered_completions = []
456
+ for item in all_completions:
457
+ if item not in seen:
458
+ seen.add(item)
459
+ filtered_completions.append(item)
460
+
461
+ return filtered_completions
462
+
463
+ def complete(self):
464
+ """Show improved completion popup with context awareness"""
465
+ import re
466
+
467
+ # Get the text under cursor
468
+ prefix = self.text_under_cursor()
469
+
470
+ # Don't show popup for empty text or too short prefixes unless it's a table prefix
471
+ is_table_prefix = '.' in self.get_word_under_cursor() and self.get_word_under_cursor().endswith('.')
472
+ if not prefix and not is_table_prefix:
473
+ if self.completer and self.completer.popup().isVisible():
474
+ self.completer.popup().hide()
475
+ return
476
+
477
+ # Get context-aware completions
478
+ if self.tables_cache:
479
+ # Use our custom context-aware completion
480
+ completions = self.get_context_aware_completions(prefix)
481
+ if completions:
482
+ # Create a temporary model for the filtered completions
483
+ model = QStringListModel()
484
+ model.setStringList(completions)
485
+ self.completer.setModel(model)
486
+
487
+ # Set the completion prefix
488
+ self.completer.setCompletionPrefix(prefix)
489
+
490
+ # If no completions, hide popup
491
+ if self.completer.completionCount() == 0:
492
+ self.completer.popup().hide()
493
+ return
494
+
495
+ # Get popup and position it under the current text
496
+ popup = self.completer.popup()
497
+ popup.setCurrentIndex(self.completer.completionModel().index(0, 0))
498
+
499
+ # Calculate position for the popup
500
+ cr = self.cursorRect()
501
+ cr.setWidth(self.completer.popup().sizeHintForColumn(0) +
502
+ self.completer.popup().verticalScrollBar().sizeHint().width())
503
+
504
+ # Show the popup
505
+ self.completer.complete(cr)
506
+
507
+ def keyPressEvent(self, event):
508
+ # Handle completer popup navigation
509
+ if self.completer and self.completer.popup().isVisible():
510
+ # Handle Tab key to complete the current selection
511
+ if event.key() == Qt.Key.Key_Tab:
512
+ # Get the SELECTED completion (not just the current one)
513
+ popup = self.completer.popup()
514
+ current_index = popup.currentIndex()
515
+ selected_completion = popup.model().data(current_index)
516
+
517
+ # Accept the selected completion and close popup
518
+ if selected_completion:
519
+ self.last_key_was_tab = True
520
+ self.completer.popup().hide()
521
+ self.insert_completion(selected_completion)
522
+ self.last_key_was_tab = False
523
+ return
524
+ event.ignore()
525
+ return
526
+
527
+ # Let Enter key escape/close the popup without completing
528
+ if event.key() in [Qt.Key.Key_Enter, Qt.Key.Key_Return]:
529
+ self.completer.popup().hide()
530
+ super().keyPressEvent(event)
531
+ return
532
+
533
+ # Let Space key escape/close the popup without completing
534
+ if event.key() == Qt.Key.Key_Space:
535
+ self.completer.popup().hide()
536
+ super().keyPressEvent(event)
537
+ return
538
+
539
+ # Hide popup on Escape
540
+ if event.key() == Qt.Key.Key_Escape:
541
+ self.completer.popup().hide()
542
+ event.ignore()
543
+ return
544
+
545
+ # Let Up/Down keys navigate the popup
546
+ if event.key() in [Qt.Key.Key_Up, Qt.Key.Key_Down]:
547
+ event.ignore()
548
+ return
549
+
550
+ # Handle special key combinations
551
+ if event.key() == Qt.Key.Key_Tab:
552
+ # Insert 4 spaces instead of a tab character
553
+ self.insertPlainText(" ")
554
+ return
555
+
556
+ # Auto-indentation for new lines
557
+ if event.key() in [Qt.Key.Key_Return, Qt.Key.Key_Enter]:
558
+ cursor = self.textCursor()
559
+ block = cursor.block()
560
+ text = block.text()
561
+
562
+ # Get the indentation of the current line
563
+ indentation = ""
564
+ for char in text:
565
+ if char.isspace():
566
+ indentation += char
567
+ else:
568
+ break
569
+
570
+ # Check if line ends with an opening bracket or keywords that should increase indentation
571
+ increase_indent = ""
572
+ if text.strip().endswith("(") or any(text.strip().upper().endswith(keyword) for keyword in
573
+ ["SELECT", "FROM", "WHERE", "GROUP BY", "ORDER BY", "HAVING"]):
574
+ increase_indent = " "
575
+
576
+ # Insert new line with proper indentation
577
+ super().keyPressEvent(event)
578
+ self.insertPlainText(indentation + increase_indent)
579
+ return
580
+
581
+ # Handle keyboard shortcuts
582
+ if event.modifiers() == Qt.KeyboardModifier.ControlModifier:
583
+ if event.key() == Qt.Key.Key_Space:
584
+ # Show completion popup
585
+ self.complete()
586
+ return
587
+ elif event.key() == Qt.Key.Key_K:
588
+ # Comment/uncomment the selected lines
589
+ self.toggle_comment()
590
+ return
591
+ elif event.key() == Qt.Key.Key_Slash:
592
+ # Also allow Ctrl+/ for commenting (common shortcut in other editors)
593
+ self.toggle_comment()
594
+ return
595
+
596
+ # For normal key presses
597
+ super().keyPressEvent(event)
598
+
599
+ # Check for autocomplete after typing
600
+ if event.text() and not event.text().isspace():
601
+ # Only show completion if user is actively typing
602
+ # Add slight delay to avoid excessive completions
603
+ if hasattr(self, '_completion_timer'):
604
+ try:
605
+ if self._completion_timer.isActive():
606
+ self._completion_timer.stop()
607
+ except:
608
+ pass
609
+
610
+ # Create a timer to trigger completion after a short delay
611
+ self._completion_timer = QTimer()
612
+ self._completion_timer.setSingleShot(True)
613
+ self._completion_timer.timeout.connect(self.complete)
614
+ self._completion_timer.start(250) # 250 ms delay for better user experience
615
+
616
+ elif event.key() == Qt.Key.Key_Backspace:
617
+ # Re-evaluate completion when backspacing, with a shorter delay
618
+ if hasattr(self, '_completion_timer'):
619
+ try:
620
+ if self._completion_timer.isActive():
621
+ self._completion_timer.stop()
622
+ except:
623
+ pass
624
+
625
+ self._completion_timer = QTimer()
626
+ self._completion_timer.setSingleShot(True)
627
+ self._completion_timer.timeout.connect(self.complete)
628
+ self._completion_timer.start(100) # 100 ms delay for backspace
629
+
630
+ else:
631
+ # Hide completion popup when inserting space or non-text characters
632
+ if self.completer and self.completer.popup().isVisible():
633
+ self.completer.popup().hide()
634
+
635
+ def paintEvent(self, event):
636
+ # Call the parent's paintEvent first
637
+ super().paintEvent(event)
638
+
639
+ # Get the current cursor
640
+ cursor = self.textCursor()
641
+
642
+ # If there's a selection, paint custom highlight
643
+ if cursor.hasSelection():
644
+ # Create a painter for this widget
645
+ painter = QPainter(self.viewport())
646
+
647
+ # Get the selection start and end positions
648
+ start = cursor.selectionStart()
649
+ end = cursor.selectionEnd()
650
+
651
+ # Create temporary cursor to get the rectangles
652
+ temp_cursor = QTextCursor(cursor)
653
+
654
+ # Move to start and get the starting position
655
+ temp_cursor.setPosition(start)
656
+ start_pos = self.cursorRect(temp_cursor)
657
+
658
+ # Move to end and get the ending position
659
+ temp_cursor.setPosition(end)
660
+ end_pos = self.cursorRect(temp_cursor)
661
+
662
+ # Set the highlight color with transparency
663
+ painter.setBrush(QBrush(self.selection_color))
664
+ painter.setPen(Qt.PenStyle.NoPen)
665
+
666
+ # Draw the highlight rectangle
667
+ if start_pos.top() == end_pos.top():
668
+ # Single line selection
669
+ painter.drawRect(QRect(start_pos.left(), start_pos.top(),
670
+ end_pos.right() - start_pos.left(), start_pos.height()))
671
+ else:
672
+ # Multi-line selection
673
+ # First line
674
+ painter.drawRect(QRect(start_pos.left(), start_pos.top(),
675
+ self.viewport().width() - start_pos.left(), start_pos.height()))
676
+
677
+ # Middle lines (if any)
678
+ if end_pos.top() > start_pos.top() + start_pos.height():
679
+ painter.drawRect(QRect(0, start_pos.top() + start_pos.height(),
680
+ self.viewport().width(),
681
+ end_pos.top() - (start_pos.top() + start_pos.height())))
682
+
683
+ # Last line
684
+ painter.drawRect(QRect(0, end_pos.top(), end_pos.right(), end_pos.height()))
685
+
686
+ painter.end()
687
+
688
+ def focusInEvent(self, event):
689
+ super().focusInEvent(event)
690
+ # Show temporary hint in status bar when editor gets focus
691
+ if hasattr(self.parent(), 'statusBar'):
692
+ self.parent().parent().parent().statusBar().showMessage('Press Ctrl+Space for autocomplete', 2000)
693
+
694
+ def toggle_comment(self):
695
+ cursor = self.textCursor()
696
+ if cursor.hasSelection():
697
+ # Get the selected text
698
+ start = cursor.selectionStart()
699
+ end = cursor.selectionEnd()
700
+
701
+ # Remember the selection
702
+ cursor.setPosition(start)
703
+ start_block = cursor.blockNumber()
704
+ cursor.setPosition(end)
705
+ end_block = cursor.blockNumber()
706
+
707
+ # Process each line in the selection
708
+ cursor.setPosition(start)
709
+ cursor.beginEditBlock()
710
+
711
+ for _ in range(start_block, end_block + 1):
712
+ # Move to start of line
713
+ cursor.movePosition(cursor.MoveOperation.StartOfLine)
714
+
715
+ # Check if the line is already commented
716
+ line_text = cursor.block().text().lstrip()
717
+ if line_text.startswith('--'):
718
+ # Remove comment
719
+ pos = cursor.block().text().find('--')
720
+ cursor.setPosition(cursor.block().position() + pos)
721
+ cursor.deleteChar()
722
+ cursor.deleteChar()
723
+ else:
724
+ # Add comment
725
+ cursor.insertText('--')
726
+
727
+ # Move to next line if not at the end
728
+ if not cursor.atEnd():
729
+ cursor.movePosition(cursor.MoveOperation.NextBlock)
730
+
731
+ cursor.endEditBlock()
732
+ else:
733
+ # Comment/uncomment current line
734
+ cursor.movePosition(cursor.MoveOperation.StartOfLine)
735
+ cursor.movePosition(cursor.MoveOperation.EndOfLine, cursor.MoveMode.KeepAnchor)
736
+ line_text = cursor.selectedText().lstrip()
737
+
738
+ cursor.movePosition(cursor.MoveOperation.StartOfLine)
739
+ if line_text.startswith('--'):
740
+ # Remove comment
741
+ pos = cursor.block().text().find('--')
742
+ cursor.setPosition(cursor.block().position() + pos)
743
+ cursor.deleteChar()
744
+ cursor.deleteChar()
745
+ else:
746
+ # Add comment
747
+ cursor.insertText('--')
748
+
749
+ def line_number_area_width(self):
750
+ digits = 1
751
+ max_num = max(1, self.blockCount())
752
+ while max_num >= 10:
753
+ max_num //= 10
754
+ digits += 1
755
+
756
+ space = 3 + self.fontMetrics().horizontalAdvance('9') * digits
757
+ return space
758
+
759
+ def update_line_number_area_width(self, _):
760
+ self.setViewportMargins(self.line_number_area_width(), 0, 0, 0)
761
+
762
+ def update_line_number_area(self, rect, dy):
763
+ if dy:
764
+ self.line_number_area.scroll(0, dy)
765
+ else:
766
+ self.line_number_area.update(0, rect.y(), self.line_number_area.width(), rect.height())
767
+
768
+ if rect.contains(self.viewport().rect()):
769
+ self.update_line_number_area_width(0)
770
+
771
+ def resizeEvent(self, event):
772
+ super().resizeEvent(event)
773
+ cr = self.contentsRect()
774
+ self.line_number_area.setGeometry(QRect(cr.left(), cr.top(), self.line_number_area_width(), cr.height()))
775
+
776
+ def line_number_area_paint_event(self, event):
777
+ painter = QPainter(self.line_number_area)
778
+ painter.fillRect(event.rect(), QColor("#f0f0f0")) # Light gray background
779
+
780
+ block = self.firstVisibleBlock()
781
+ block_number = block.blockNumber()
782
+ top = round(self.blockBoundingGeometry(block).translated(self.contentOffset()).top())
783
+ bottom = top + round(self.blockBoundingRect(block).height())
784
+
785
+ while block.isValid() and top <= event.rect().bottom():
786
+ if block.isVisible() and bottom >= event.rect().top():
787
+ number = str(block_number + 1)
788
+ painter.setPen(QColor("#808080")) # Gray text
789
+ painter.drawText(0, top, self.line_number_area.width() - 5,
790
+ self.fontMetrics().height(),
791
+ Qt.AlignmentFlag.AlignRight, number)
792
+
793
+ block = block.next()
794
+ top = bottom
795
+ bottom = top + round(self.blockBoundingRect(block).height())
796
+ block_number += 1
797
+
798
+ def dragEnterEvent(self, event):
799
+ """Handle drag enter events to allow dropping table names."""
800
+ # Accept text/plain mime data (used for table names)
801
+ if event.mimeData().hasText():
802
+ event.acceptProposedAction()
803
+ else:
804
+ event.ignore()
805
+
806
+ def dragMoveEvent(self, event):
807
+ """Handle drag move events to show valid drop locations."""
808
+ if event.mimeData().hasText():
809
+ event.acceptProposedAction()
810
+ else:
811
+ event.ignore()
812
+
813
+ def dropEvent(self, event):
814
+ """Handle drop event to insert table name at cursor position."""
815
+ if event.mimeData().hasText():
816
+ # Get table name from dropped text
817
+ text = event.mimeData().text()
818
+
819
+ # Extract actual table name (if it includes parentheses)
820
+ if " (" in text:
821
+ table_name = text.split(" (")[0]
822
+ else:
823
+ table_name = text
824
+
825
+ # Get current cursor position and surrounding text
826
+ cursor = self.textCursor()
827
+ document = self.document()
828
+ current_block = cursor.block()
829
+ block_text = current_block.text()
830
+ position_in_block = cursor.positionInBlock()
831
+
832
+ # Get text before cursor in current line
833
+ text_before = block_text[:position_in_block].strip().upper()
834
+
835
+ # Determine how to insert the table name based on context
836
+ if (text_before.endswith("FROM") or
837
+ text_before.endswith("JOIN") or
838
+ text_before.endswith("INTO") or
839
+ text_before.endswith("UPDATE") or
840
+ text_before.endswith(",")):
841
+ # Just insert the table name with a space before it
842
+ cursor.insertText(f" {table_name}")
843
+ elif text_before.endswith("FROM ") or text_before.endswith("JOIN ") or text_before.endswith("INTO ") or text_before.endswith(", "):
844
+ # Just insert the table name without a space
845
+ cursor.insertText(table_name)
846
+ elif not text_before and not block_text:
847
+ # If at empty line, insert a SELECT statement
848
+ cursor.insertText(f"SELECT * FROM {table_name}")
849
+ else:
850
+ # Default: just insert the table name at cursor position
851
+ cursor.insertText(table_name)
852
+
853
+ # Accept the action
854
+ event.acceptProposedAction()
855
+ else:
856
+ event.ignore()