Skip to content

Autocomplete #513

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 13 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions appveyor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ install:
- 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
- sh: bash miniconda.sh -b -p $HOME/miniconda
- sh: source $HOME/miniconda/bin/activate
- cmd: curl -fsSL -o miniconda.exe https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-Windows-x86_64.exe
- cmd: curl -fsSL -o miniconda.exe https://github.com/conda-forge/miniforge/releases/download/24.11.3-2/Miniforge3-Windows-x86_64.exe
- cmd: miniconda.exe /S /InstallationType=JustMe /D=%MINICONDA_DIRNAME%
- cmd: set "PATH=%MINICONDA_DIRNAME%;%MINICONDA_DIRNAME%\\Scripts;%PATH%"
- cmd: activate
- mamba info
- mamba env create --name cqgui -f cqgui_env.yml
- mamba env create -y --name cqgui -f cqgui_env.yml
- sh: source activate cqgui
- cmd: activate cqgui
- mamba list
Expand Down
14 changes: 7 additions & 7 deletions azure-pipelines.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@ parameters:
default:
- 11

stages:
stages:
- stage: build_conda_package
jobs:
- ${{ each minor in parameters.minor }}:
- template: conda-build.yml@templates
parameters:
name: Linux
vmImage: 'ubuntu-latest'
vmImage: 'ubuntu-20.04'
py_maj: 3
py_min: ${{minor}}
conda_bld: 3.21.6
Expand All @@ -39,11 +39,11 @@ stages:
- template: constructor-build.yml@templates
parameters:
name: linux
vmImage: 'ubuntu-latest'
vmImage: 'ubuntu-20.04'
- template: constructor-build.yml@templates
parameters:
name: win
vmImage: 'windows-latest'
vmImage: 'windows-2019'
- template: constructor-build.yml@templates
parameters:
name: macos
Expand All @@ -54,7 +54,7 @@ stages:
- job: upload_to_github
condition: ne(variables['Build.Reason'], 'PullRequest')
pool:
vmImage: ubuntu-latest
vmImage: ubuntu-20.04
steps:
- download: current
artifact: installer_ubuntu-latest
Expand All @@ -76,11 +76,11 @@ stages:

# stage left for debugging, disabled by default
- stage: verify
condition: False
condition: False
jobs:
- job: verify_linux
pool:
vmImage: ubuntu-latest
vmImage: ubuntu-20.04
steps:
- download: current
artifact: installer_ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions cq_editor/icons.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
},
),
"toggle-comment": (("fa.hashtag",), {}),
"search": (("fa.search",), {}),
}


Expand Down
20 changes: 20 additions & 0 deletions cq_editor/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,16 @@ def prepare_menubar(self):
triggered=self.components["editor"].toggle_comment,
)
)
# Add the menu action to toggle auto-completion
menu_edit.addAction(
QAction(
icon("search"),
"Auto-Complete",
self,
shortcut="alt+/",
triggered=self.components["editor"]._trigger_autocomplete,
)
)
menu_edit.addAction(
QAction(
icon("preferences"),
Expand Down Expand Up @@ -429,6 +439,8 @@ def prepare_actions(self):
self.components["editor"].sigFilenameChanged.connect(
self.handle_filename_change
)
# Allows updating of the status bar from the Editor
self.components["editor"].statusChanged.connect(self.update_statusbar)

def prepare_console(self):

Expand Down Expand Up @@ -518,6 +530,14 @@ def update_window_title(self, modified):
title += "*"
self.setWindowTitle(title)

def update_statusbar(self, status_text):
"""
Allow updating the status bar with information.
"""

# Update the statusbar text
self.status_label.setText(status_text)


if __name__ == "__main__":

Expand Down
187 changes: 184 additions & 3 deletions cq_editor/widgets/editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,22 @@
from modulefinder import ModuleFinder

from spyder.plugins.editor.widgets.codeeditor import CodeEditor
from PyQt5.QtCore import pyqtSignal, QFileSystemWatcher, QTimer
from PyQt5.QtWidgets import QAction, QFileDialog, QApplication
from PyQt5.QtGui import QFontDatabase, QTextCursor
from PyQt5.QtCore import pyqtSignal, QFileSystemWatcher, QTimer, Qt, QEvent
from PyQt5.QtWidgets import (
QAction,
QFileDialog,
QApplication,
QListWidget,
QListWidgetItem,
QShortcut,
)
from PyQt5.QtGui import QFontDatabase, QTextCursor, QKeyEvent
from path import Path

import sys

import jedi

from pyqtgraph.parametertree import Parameter

from ..mixins import ComponentMixin
Expand All @@ -26,6 +35,7 @@
# autoreload is enabled.
triggerRerender = pyqtSignal(bool)
sigFilenameChanged = pyqtSignal(str)
statusChanged = pyqtSignal(str)

preferences = Parameter.create(
name="Preferences",
Expand Down Expand Up @@ -54,6 +64,9 @@
# Tracks whether or not the document was saved from the Spyder editor vs an external editor
was_modified_by_self = False

# Helps display the completion list for the editor
completion_list = None

def __init__(self, parent=None):

self._watched_file = None
Expand Down Expand Up @@ -120,6 +133,53 @@

self.updatePreferences()

# Create a floating list widget for completions
self.completion_list = QListWidget(self)
self.completion_list.setWindowFlags(Qt.Popup)
self.completion_list.setFocusPolicy(Qt.NoFocus)
self.completion_list.hide()

# Connect the completion list to the editor
self.completion_list.itemClicked.connect(self.insert_completion)

# Ensure that when the escape key is pressed with the completion_list in focus, it will be hidden
self.completion_list.installEventFilter(self)

def eventFilter(self, watched, event):
"""
Allows us to do things like escape and tab key press for the completion list.
"""

if watched == self.completion_list and event.type() == QEvent.KeyPress:
key_event = QKeyEvent(event)

Check warning on line 154 in cq_editor/widgets/editor.py

View check run for this annotation

Codecov / codecov/patch

cq_editor/widgets/editor.py#L154

Added line #L154 was not covered by tests
# Handle the escape key press
if key_event.key() == Qt.Key_Escape:
if self.completion_list and self.completion_list.isVisible():
self.completion_list.hide()
return True # Event handled

Check warning on line 159 in cq_editor/widgets/editor.py

View check run for this annotation

Codecov / codecov/patch

cq_editor/widgets/editor.py#L158-L159

Added lines #L158 - L159 were not covered by tests
# Handle the tab key press
elif key_event.key() == Qt.Key_Tab:
if self.completion_list and self.completion_list.isVisible():
self.insert_completion(self.completion_list.currentItem())
return True # Event handled

Check warning on line 164 in cq_editor/widgets/editor.py

View check run for this annotation

Codecov / codecov/patch

cq_editor/widgets/editor.py#L163-L164

Added lines #L163 - L164 were not covered by tests
elif key_event.key() == Qt.Key_Return:
if self.completion_list and self.completion_list.isVisible():
self.insert_completion(self.completion_list.currentItem())
return True # Event handled

Check warning on line 168 in cq_editor/widgets/editor.py

View check run for this annotation

Codecov / codecov/patch

cq_editor/widgets/editor.py#L167-L168

Added lines #L167 - L168 were not covered by tests

# Let the event propagate to the editor
return False

Check warning on line 171 in cq_editor/widgets/editor.py

View check run for this annotation

Codecov / codecov/patch

cq_editor/widgets/editor.py#L171

Added line #L171 was not covered by tests

# Let the event propagate to the editor
return False

def hide_completion_list(self):
"""
Hide the completion list.
"""
if self.completion_list and self.completion_list.isVisible():
self.completion_list.hide()

Check warning on line 181 in cq_editor/widgets/editor.py

View check run for this annotation

Codecov / codecov/patch

cq_editor/widgets/editor.py#L181

Added line #L181 was not covered by tests

def _fixContextMenu(self):

menu = self.menu
Expand Down Expand Up @@ -260,6 +320,127 @@
if module_paths:
self._file_watcher.addPaths(module_paths)

def _trigger_autocomplete(self):
"""
Allows the user to ask for autocomplete suggestions.
"""

# Clear the status bar
self.statusChanged.emit("")

# Track whether or not there are any completions to show
completions_present = False

script = jedi.Script(self.toPlainText(), path=self.filename)

# Clear the completion list
self.completion_list.clear()

# Check to see if the character before the cursor is an open parenthesis
cursor_pos = self.textCursor().position()
text_before_cursor = self.toPlainText()[:cursor_pos]
text_after_cursor = self.toPlainText()[cursor_pos:]
if text_before_cursor.endswith("("):
# If there is a trailing close parentheis after the cursor, remove it
if text_after_cursor.startswith(")"):
self.textCursor().deleteChar()

Check warning on line 346 in cq_editor/widgets/editor.py

View check run for this annotation

Codecov / codecov/patch

cq_editor/widgets/editor.py#L346

Added line #L346 was not covered by tests

# Update the script with the modified text
script = jedi.Script(self.toPlainText(), path=self.filename)

Check warning on line 349 in cq_editor/widgets/editor.py

View check run for this annotation

Codecov / codecov/patch

cq_editor/widgets/editor.py#L349

Added line #L349 was not covered by tests

# Check if there are any function signatures
signatures = script.get_signatures()

Check warning on line 352 in cq_editor/widgets/editor.py

View check run for this annotation

Codecov / codecov/patch

cq_editor/widgets/editor.py#L352

Added line #L352 was not covered by tests
if signatures:
# Let the rest of the code know that there was a completion
completions_present = True

Check warning on line 355 in cq_editor/widgets/editor.py

View check run for this annotation

Codecov / codecov/patch

cq_editor/widgets/editor.py#L355

Added line #L355 was not covered by tests

# Load the signatures into the completion list
for signature in signatures:
# Build a human-readable signature
i = 0
cur_signature = f"{signature.name}("

Check warning on line 361 in cq_editor/widgets/editor.py

View check run for this annotation

Codecov / codecov/patch

cq_editor/widgets/editor.py#L360-L361

Added lines #L360 - L361 were not covered by tests
for param in signature.params:
# Prevent trailing comma in parameter list
param_ending = ","

Check warning on line 364 in cq_editor/widgets/editor.py

View check run for this annotation

Codecov / codecov/patch

cq_editor/widgets/editor.py#L364

Added line #L364 was not covered by tests
if i == len(signature.params) - 1:
param_ending = ""

Check warning on line 366 in cq_editor/widgets/editor.py

View check run for this annotation

Codecov / codecov/patch

cq_editor/widgets/editor.py#L366

Added line #L366 was not covered by tests

# If the parameter is optional, do not overload the user with it
if "Optional" in param.description:
i += 1
continue

Check warning on line 371 in cq_editor/widgets/editor.py

View check run for this annotation

Codecov / codecov/patch

cq_editor/widgets/editor.py#L370-L371

Added lines #L370 - L371 were not covered by tests

if "=" in param.description:
cur_signature += f"{param.name}={param.description.split('=')[1].strip()}{param_ending}"

Check warning on line 374 in cq_editor/widgets/editor.py

View check run for this annotation

Codecov / codecov/patch

cq_editor/widgets/editor.py#L374

Added line #L374 was not covered by tests
else:
cur_signature += f"{param.name}{param_ending}"
i += 1
cur_signature += ")"

Check warning on line 378 in cq_editor/widgets/editor.py

View check run for this annotation

Codecov / codecov/patch

cq_editor/widgets/editor.py#L376-L378

Added lines #L376 - L378 were not covered by tests

# Add the current signature to the list
item = QListWidgetItem(cur_signature)
self.completion_list.addItem(item)

Check warning on line 382 in cq_editor/widgets/editor.py

View check run for this annotation

Codecov / codecov/patch

cq_editor/widgets/editor.py#L381-L382

Added lines #L381 - L382 were not covered by tests
else:
completions = script.complete()
if completions:
# Let the rest of the code know that there was a completion
completions_present = True

# Add completions to the list
for completion in completions:
item = QListWidgetItem(completion.name)
self.completion_list.addItem(item)

# Only show the completions list if there were any
if completions_present:
# Position the list near the cursor
cursor_rect = self.cursorRect()
global_pos = self.mapToGlobal(cursor_rect.bottomLeft())
self.completion_list.move(global_pos)

# Show the completion list
self.completion_list.show()

# Select the first item in the list
self.completion_list.setCurrentRow(0)
else:
# Let the user know that no completions are available
self.statusChanged.emit("No completions available")

Check warning on line 408 in cq_editor/widgets/editor.py

View check run for this annotation

Codecov / codecov/patch

cq_editor/widgets/editor.py#L408

Added line #L408 was not covered by tests

def insert_completion(self, item):
"""
Inserts the selected completion into the editor.
"""

# If there is an open parenthesis before the cursor, replace it with the completion
if (
self.textCursor().position() > 0
and self.toPlainText()[self.textCursor().position() - 1] == "("
):
cursor = self.textCursor()
cursor.setPosition(cursor.position() - 1)
cursor.movePosition(QTextCursor.EndOfWord, QTextCursor.KeepAnchor)
cursor.removeSelectedText()

Check warning on line 423 in cq_editor/widgets/editor.py

View check run for this annotation

Codecov / codecov/patch

cq_editor/widgets/editor.py#L420-L423

Added lines #L420 - L423 were not covered by tests

# Find the last period in the text
text_before_cursor = self.toPlainText()[: self.textCursor().position()]
last_period_index = text_before_cursor.rfind(".")

# Move the cursor to just after the last period position
cursor = self.textCursor()
cursor.setPosition(last_period_index + 1)

# Remove text after last period
cursor.movePosition(QTextCursor.EndOfWord, QTextCursor.KeepAnchor)
cursor.removeSelectedText()

# Insert the completion text
cursor.insertText(item.text())
self.setTextCursor(cursor)

# Hide the completion list
self.completion_list.hide()

# callback triggered by QFileSystemWatcher
def _file_changed(self):
# neovim writes a file by removing it first so must re-add each time
Expand Down
38 changes: 38 additions & 0 deletions tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1710,3 +1710,41 @@ def test_light_dark_mode(main):

# Check that the dark mode stylesheet is different from the light mode stylesheet
assert dark_bg != light_bg


def test_autocomplete(main):
qtbot, win = main

editor = win.components["editor"]
# debugger = win.components["debugger"]
# log = win.components["log"]

# Set some text that should give a couple of auto-complete options
editor.set_text(r"""import cadquery as cq\nres = cq.W""")

# Set the cursor position to the end of the text
editor.set_cursor_position(len(editor.get_text_with_eol()))

# Trigger auto-complete
editor._trigger_autocomplete()
qtbot.wait(100)

# Check that the completion list has two items
assert len(editor.completion_list) == 2

# Select the first item in the completion list
editor.completion_list.setCurrentRow(1)

# Wait for the completion to be applied
qtbot.wait(100)

# Simulate a click on the second item in the list
editor.completion_list.itemClicked.emit(editor.completion_list.item(1))

# Wait for the completion to be applied
qtbot.wait(100)

# Check that the text has been completed
assert (
editor.get_text_with_eol() == r"""import cadquery as cq\nres = cq.Workplane"""
)
Loading