Skip to content

Commit e720033

Browse files
authored
Autocomplete (#513)
* First attempt at custom auto-complete in the editor * Improved autocomplete and tried to increase coverage a bit * Improved auto-complete a bit with signatures and feedback for the user on no-completions * Added completion_list as a class variable * Added a test for user interaction keys * Switching to keyClick to try to fix the error on Ubuntu in CI * Skipping keystroke test in Linux for now because of headless CI issue
1 parent 6ddf0b4 commit e720033

File tree

6 files changed

+336
-5
lines changed

6 files changed

+336
-5
lines changed

appveyor.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ install:
1717
- sh: if [[ $APPVEYOR_BUILD_WORKER_IMAGE == "macOS"* ]]; then curl -fsSL -o miniconda.sh https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-Darwin-x86_64.sh; fi
1818
- sh: bash miniconda.sh -b -p $HOME/miniconda
1919
- sh: source $HOME/miniconda/bin/activate
20-
- cmd: curl -fsSL -o miniconda.exe https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-Windows-x86_64.exe
20+
- cmd: curl -fsSL -o miniconda.exe https://github.com/conda-forge/miniforge/releases/download/24.11.3-2/Miniforge3-Windows-x86_64.exe
2121
- cmd: miniconda.exe /S /InstallationType=JustMe /D=%MINICONDA_DIRNAME%
2222
- cmd: set "PATH=%MINICONDA_DIRNAME%;%MINICONDA_DIRNAME%\\Scripts;%PATH%"
2323
- cmd: activate

azure-pipelines.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,4 +120,4 @@ stages:
120120
env:
121121
PYTHON_VERSION: 3.${{ minor }}
122122
PACKAGE_VERSION: $(Build.SourceBranchName)
123-
TOKEN: $(anaconda.TOKEN)
123+
TOKEN: $(anaconda.TOKEN)

cq_editor/icons.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
},
7272
),
7373
"toggle-comment": (("fa.hashtag",), {}),
74+
"search": (("fa.search",), {}),
7475
}
7576

7677

cq_editor/main_window.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,16 @@ def prepare_menubar(self):
301301
triggered=self.components["editor"].toggle_comment,
302302
)
303303
)
304+
# Add the menu action to toggle auto-completion
305+
menu_edit.addAction(
306+
QAction(
307+
icon("search"),
308+
"Auto-Complete",
309+
self,
310+
shortcut="alt+/",
311+
triggered=self.components["editor"]._trigger_autocomplete,
312+
)
313+
)
304314
menu_edit.addAction(
305315
QAction(
306316
icon("preferences"),
@@ -429,6 +439,8 @@ def prepare_actions(self):
429439
self.components["editor"].sigFilenameChanged.connect(
430440
self.handle_filename_change
431441
)
442+
# Allows updating of the status bar from the Editor
443+
self.components["editor"].statusChanged.connect(self.update_statusbar)
432444

433445
def prepare_console(self):
434446

@@ -518,6 +530,14 @@ def update_window_title(self, modified):
518530
title += "*"
519531
self.setWindowTitle(title)
520532

533+
def update_statusbar(self, status_text):
534+
"""
535+
Allow updating the status bar with information.
536+
"""
537+
538+
# Update the statusbar text
539+
self.status_label.setText(status_text)
540+
521541

522542
if __name__ == "__main__":
523543

cq_editor/widgets/editor.py

Lines changed: 177 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,22 @@
33
from modulefinder import ModuleFinder
44

55
from spyder.plugins.editor.widgets.codeeditor import CodeEditor
6-
from PyQt5.QtCore import pyqtSignal, QFileSystemWatcher, QTimer
7-
from PyQt5.QtWidgets import QAction, QFileDialog, QApplication
8-
from PyQt5.QtGui import QFontDatabase, QTextCursor
6+
from PyQt5.QtCore import pyqtSignal, QFileSystemWatcher, QTimer, Qt, QEvent
7+
from PyQt5.QtWidgets import (
8+
QAction,
9+
QFileDialog,
10+
QApplication,
11+
QListWidget,
12+
QListWidgetItem,
13+
QShortcut,
14+
)
15+
from PyQt5.QtGui import QFontDatabase, QTextCursor, QKeyEvent
916
from path import Path
1017

1118
import sys
1219

20+
import jedi
21+
1322
from pyqtgraph.parametertree import Parameter
1423

1524
from ..mixins import ComponentMixin
@@ -26,6 +35,7 @@ class Editor(CodeEditor, ComponentMixin):
2635
# autoreload is enabled.
2736
triggerRerender = pyqtSignal(bool)
2837
sigFilenameChanged = pyqtSignal(str)
38+
statusChanged = pyqtSignal(str)
2939

3040
preferences = Parameter.create(
3141
name="Preferences",
@@ -54,6 +64,9 @@ class Editor(CodeEditor, ComponentMixin):
5464
# Tracks whether or not the document was saved from the Spyder editor vs an external editor
5565
was_modified_by_self = False
5666

67+
# Helps display the completion list for the editor
68+
completion_list = None
69+
5770
def __init__(self, parent=None):
5871

5972
self._watched_file = None
@@ -120,6 +133,46 @@ def __init__(self, parent=None):
120133

121134
self.updatePreferences()
122135

136+
# Create a floating list widget for completions
137+
self.completion_list = QListWidget(self)
138+
self.completion_list.setWindowFlags(Qt.Popup)
139+
self.completion_list.setFocusPolicy(Qt.NoFocus)
140+
self.completion_list.hide()
141+
142+
# Connect the completion list to the editor
143+
self.completion_list.itemClicked.connect(self.insert_completion)
144+
145+
# Ensure that when the escape key is pressed with the completion_list in focus, it will be hidden
146+
self.completion_list.installEventFilter(self)
147+
148+
def eventFilter(self, watched, event):
149+
"""
150+
Allows us to do things like escape and tab key press for the completion list.
151+
"""
152+
153+
if watched == self.completion_list and event.type() == QEvent.KeyPress:
154+
key_event = QKeyEvent(event)
155+
# Handle the escape key press
156+
if key_event.key() == Qt.Key_Escape:
157+
if self.completion_list and self.completion_list.isVisible():
158+
self.completion_list.hide()
159+
return True # Event handled
160+
# Handle the tab key press
161+
elif key_event.key() == Qt.Key_Tab:
162+
if self.completion_list and self.completion_list.isVisible():
163+
self.insert_completion(self.completion_list.currentItem())
164+
return True # Event handled
165+
elif key_event.key() == Qt.Key_Return:
166+
if self.completion_list and self.completion_list.isVisible():
167+
self.insert_completion(self.completion_list.currentItem())
168+
return True # Event handled
169+
170+
# Let the event propagate to the editor
171+
return False
172+
173+
# Let the event propagate to the editor
174+
return False
175+
123176
def _fixContextMenu(self):
124177

125178
menu = self.menu
@@ -260,6 +313,127 @@ def _watch_paths(self):
260313
if module_paths:
261314
self._file_watcher.addPaths(module_paths)
262315

316+
def _trigger_autocomplete(self):
317+
"""
318+
Allows the user to ask for autocomplete suggestions.
319+
"""
320+
321+
# Clear the status bar
322+
self.statusChanged.emit("")
323+
324+
# Track whether or not there are any completions to show
325+
completions_present = False
326+
327+
script = jedi.Script(self.toPlainText(), path=self.filename)
328+
329+
# Clear the completion list
330+
self.completion_list.clear()
331+
332+
# Check to see if the character before the cursor is an open parenthesis
333+
cursor_pos = self.textCursor().position()
334+
text_before_cursor = self.toPlainText()[:cursor_pos]
335+
text_after_cursor = self.toPlainText()[cursor_pos:]
336+
if text_before_cursor.endswith("("):
337+
# If there is a trailing close parentheis after the cursor, remove it
338+
if text_after_cursor.startswith(")"):
339+
self.textCursor().deleteChar()
340+
341+
# Update the script with the modified text
342+
script = jedi.Script(self.toPlainText(), path=self.filename)
343+
344+
# Check if there are any function signatures
345+
signatures = script.get_signatures()
346+
if signatures:
347+
# Let the rest of the code know that there was a completion
348+
completions_present = True
349+
350+
# Load the signatures into the completion list
351+
for signature in signatures:
352+
# Build a human-readable signature
353+
i = 0
354+
cur_signature = f"{signature.name}("
355+
for param in signature.params:
356+
# Prevent trailing comma in parameter list
357+
param_ending = ","
358+
if i == len(signature.params) - 1:
359+
param_ending = ""
360+
361+
# If the parameter is optional, do not overload the user with it
362+
if "Optional" in param.description:
363+
i += 1
364+
continue
365+
366+
if "=" in param.description:
367+
cur_signature += f"{param.name}={param.description.split('=')[1].strip()}{param_ending}"
368+
else:
369+
cur_signature += f"{param.name}{param_ending}"
370+
i += 1
371+
cur_signature += ")"
372+
373+
# Add the current signature to the list
374+
item = QListWidgetItem(cur_signature)
375+
self.completion_list.addItem(item)
376+
else:
377+
completions = script.complete()
378+
if completions:
379+
# Let the rest of the code know that there was a completion
380+
completions_present = True
381+
382+
# Add completions to the list
383+
for completion in completions:
384+
item = QListWidgetItem(completion.name)
385+
self.completion_list.addItem(item)
386+
387+
# Only show the completions list if there were any
388+
if completions_present:
389+
# Position the list near the cursor
390+
cursor_rect = self.cursorRect()
391+
global_pos = self.mapToGlobal(cursor_rect.bottomLeft())
392+
self.completion_list.move(global_pos)
393+
394+
# Show the completion list
395+
self.completion_list.show()
396+
397+
# Select the first item in the list
398+
self.completion_list.setCurrentRow(0)
399+
else:
400+
# Let the user know that no completions are available
401+
self.statusChanged.emit("No completions available")
402+
403+
def insert_completion(self, item):
404+
"""
405+
Inserts the selected completion into the editor.
406+
"""
407+
408+
# If there is an open parenthesis before the cursor, replace it with the completion
409+
if (
410+
self.textCursor().position() > 0
411+
and self.toPlainText()[self.textCursor().position() - 1] == "("
412+
):
413+
cursor = self.textCursor()
414+
cursor.setPosition(cursor.position() - 1)
415+
cursor.movePosition(QTextCursor.EndOfWord, QTextCursor.KeepAnchor)
416+
cursor.removeSelectedText()
417+
418+
# Find the last period in the text
419+
text_before_cursor = self.toPlainText()[: self.textCursor().position()]
420+
last_period_index = text_before_cursor.rfind(".")
421+
422+
# Move the cursor to just after the last period position
423+
cursor = self.textCursor()
424+
cursor.setPosition(last_period_index + 1)
425+
426+
# Remove text after last period
427+
cursor.movePosition(QTextCursor.EndOfWord, QTextCursor.KeepAnchor)
428+
cursor.removeSelectedText()
429+
430+
# Insert the completion text
431+
cursor.insertText(item.text())
432+
self.setTextCursor(cursor)
433+
434+
# Hide the completion list
435+
self.completion_list.hide()
436+
263437
# callback triggered by QFileSystemWatcher
264438
def _file_changed(self):
265439
# neovim writes a file by removing it first so must re-add each time

0 commit comments

Comments
 (0)