我正在尝试创建一个 GUI(使用 PyQt5)文本完成功能,它支持类似于编程 IDE 的功能,其中我有对象,对象有字段和子字段,可以通过“.”访问。例如,如果用户输入“obj.fi”,我希望它建议“field1”,如果用户输入“obj.field1.s”,它应该建议“subfield1,subfield2等...”
我已经能够使用以下脚本在 QLineEdit 小部件上运行它:
import sys, re
from PyQt5 import QtCore
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QStandardItemModel, QStandardItem
from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QLineEdit, QCompleter
test_model_data = [
('obj1',[ ('field1',[('subfield1',[])]),
('field2',[])]),
('obj2',[ ('field1',[('subfiedl1',[])]),
('field2',[])]),
('obj3',[]),
('music',[('melody',[]),
('harmony',[('chords',[]),
('rythm',[])])])
]
class CodeCompleter(QCompleter):
ConcatenationRole = Qt.UserRole + 1
def __init__(self, data, parent=None):
super().__init__(parent)
self.createModel(data)
# Pattern to match the "last word" of an expression
# Must start with a letter, and be followed by any number of letter,numbers, undrscores or periods(.)
self.rePtrn = re.compile(r'\b[a-zA-Z][a-zA-Z0-9_.]*$')
def splitPath(self, path):
''' This function gets called when the text changes,
and we need to select how the new text is to be used to filter options
'''
lastWord = (self.rePtrn.findall(path) + [''])[0] #Append empty string, to deal with empty list exception
return lastWord.split('.')
def pathFromIndex(self, ix):
''' This function is responsible for generating the "autocompletedText"
Whatever this function returns is the final text on the widget
'''
currentText = self.widget().text()
completionText = ix.data(CodeCompleter.ConcatenationRole)
newText, wasFound = self.rePtrn.subn(completionText, currentText)
if not wasFound:
newText = currentText + completionText
return newText
def createModel(self, data):
def addItems(parent, elements, t=""):
for text, children in elements:
item = QStandardItem(text)
data = t + "." + text if t else text
item.setData(data)#, CodeCompleter.ConcatenationRole)
parent.appendRow(item)
if children:
addItems(item, children, data)
model = QStandardItemModel(self)
addItems(model, data)
self.setModel(model)
class mainApp(QWidget):
def __init__(self):
super().__init__()
self.completer = CodeCompleter(test_model_data, self)
layout = QVBoxLayout()
self.setLayout(layout)
for i in range(5):
entry = QLineEdit(self)
entry.setCompleter(self.completer)
layout.addWidget(entry)
if __name__ == "__main__":
app = QApplication(sys.argv)
hwind = mainApp()
hwind.show()
sys.exit(app.exec_())
但是,这仍然存在一些问题。特别是,我可能并不总是希望获得有关“最后一个单词”的建议,而是实际上想要获得有关“当前单词”的建议,如光标当前所在的位置。其次,我也可能更喜欢 QTextEdit 小部件,因为此后墙可能需要多行功能。
当我试图解决这两个问题时,我彻底碰壁了。看起来 QCompleter 对于 QTextEdit 的设置必须与 QLineEdit 完全不同,并且不太清楚为什么会出现这种情况。
我能做的最好的就是这样:
import sys
from PyQt5.QtWidgets import QApplication, QWidget, QTextEdit, QVBoxLayout, QCompleter, QLabel
from PyQt5 import QtGui, QtCore
from PyQt5.QtCore import Qt, QRect
from PyQt5.QtGui import QTextCursor, QStandardItemModel, QStandardItem, QPalette
test_model_data = [
('obj1',[ ('field1',[('subfield1',[])]),
('field2',[])]),
('obj2',[ ('field1',[('subfiedl1',[])]),
('field2',[])]),
('obj3',[]),
('music',[('melody',[]),
('harmony',[('chords',[]),
('rythm',[])])])
]
class CustomCompleter(QCompleter):
def __init__(self, *args, **kwargs):
QCompleter.__init__(self, *args, **kwargs)
self.createModel(test_model_data)
def createModel(self, data):
def addItems(parent, elements, t=""):
for text, children in elements:
item = QStandardItem(text)
data = t + "." + text if t else text
item.setData(data)#, CodeCompleter.ConcatenationRole)
parent.appendRow(item)
if children:
addItems(item, children, data)
model = QStandardItemModel(self)
model.splitPath = lambda t: print(t)
addItems(model, data)
self.setModel(model)
def splitPaths(self, path):
print(path)
def complete(self, rect, prefix):
self.setCompletionPrefix(prefix)
super().complete(rect)
class CustomTextEdit(QTextEdit):
def __init__(self, idx, completer, initial_text, *args, **kwargs):
super().__init__(initial_text, *args, **kwargs)
self.completer = completer
self.idx = idx
completer.activated.connect(self.insertCompletion)
def keyPressEvent(self, event):
if event.key() == Qt.Key_Tab: # Show suggestions when Tab is pressed
self.showSuggestions()
elif event.key() == Qt.Key_Return and self.completer.popup().isVisible(): # Replace suggestion when Enter is pressed
self.insertCompletion()
self.completer.popup().hide()
else:
super().keyPressEvent(event)
def insertCompletion(self):
if not self.completer.widget() is self: return
popup_index = self.completer.popup().currentIndex()
completion = self.completer.popup().model().data(popup_index)
cursor = self.textCursor()
cursor.movePosition(cursor.StartOfWord)
cursor.movePosition(cursor.EndOfWord, cursor.KeepAnchor)
cursor.insertText(completion)
def currentWord(self):
cursor = self.textCursor()
cursor.movePosition(cursor.WordLeft)
cursor.select(cursor.WordUnderCursor)
word = cursor.selectedText()
#print('Cur word: "%s"' %word)
return word
def showSuggestions(self):
cursor_rect = self.cursorRect()
popup_height = self.completer.popup().sizeHintForColumn(0) # Get the height of the popup
popup_size = self.completer.popup().sizeHint()
popup_rect = QRect(cursor_rect.bottomRight(), popup_size)
popup_rect.setHeight(popup_height) # Set the height of the popup
popup_rect.moveTop(popup_rect.top() - popup_height) # Move the top of the popup
#print( popup_rect )
#self.completer.setWidget(None)
self.completer.setWidget(self)
self.completer.complete(popup_rect, self.currentWord()) # Show popup with suggestions
class MainWindow(QWidget):
def __init__(self):
super().__init__()
self.initUI()
def initUI(self):
layout = QVBoxLayout()
completer = CustomCompleter()
# Create multiple text widgets and connect them to the same completer instance
text_edit1 = CustomTextEdit(1, completer, "Hello my friend")
text_edit2 = CustomTextEdit(2, completer, "For lunch we have fruit")
text_edit3 = CustomTextEdit(3, completer, "And then maybe a movie")
layout.addWidget(QLabel("Type below (press Tab for suggestions, Enter to replace):"))
layout.addWidget(text_edit1)
layout.addWidget(text_edit2)
layout.addWidget(text_edit3)
self.setLayout(layout)
self.setWindowTitle("Multiline Textbox with Dropdown")
self.setGeometry(100, 100, 400, 300)
self.show()
if __name__ == "__main__":
app = QApplication(sys.argv)
window = MainWindow()
sys.exit(app.exec_())
它至少具有作用于“当前单词”以及多行 QEditText 小部件的功能。但是,我完全失去了“嵌套”字段的功能。在这种方法中,“splitPath”函数似乎被完全忽略,我找不到任何合适的方法来基于“分割路径”方法“过滤”我的建议(除了从头开始重写整个模型/搜索/过滤器)。
有没有什么好的方法可以将“当前对象单词”(例如“obj1.field1.su”)发送给完成者,并使用“splitPath”在“.”处将其打破,通过QStandardItemModel进行匹配字符,就像前面的例子一样?
我对这个答案不是很满意。基本上我必须编写自己的嵌套模型,它与 QStringModel 上的包装器一起使用。我还必须修改 QCompleter 才能使用它。我觉得由于我自己的无知,我没有完全/正确地利用 PyQt5 库,而且我在 Python 上做了很多工作,我担心这会影响性能。
但它目前正在做最初问题所要求的事情,所以我将其留在这里,以防它可以帮助其他遇到同样问题的人:
import sys
import re
from PyQt5.QtWidgets import QApplication, QWidget, QTextEdit, QVBoxLayout, QCompleter, QLabel, QPushButton
from PyQt5.QtCore import Qt, QRect, QStringListModel, QPropertyAnimation
test_model_data = [
('obj1',[ ('field1',[('subfield1',[])]),
('field2',[])]),
('obj2',[ ('field1',[('subfiedl1',[])]),
('field2',[])]),
('obj3',[]),
('music',[('melody',[]),
('harmony',[('chords',[]),
('rythm',[])])])
]
class NestedDictCompleteModel:
''' This wraps a QStringModel so that it allows for recursive models,
supporting a syntax of "obj.field.subfield.subSubfield"
It creates a recursive Dict structure, to search through the fields quickly
'''
def __init__(self):
self.itemDict = {}
self.stringModel = QStringListModel()
def addItem(self, itemData):
''' Creates a new Item at this level with the text=itemData
Returns the "empty" model of the new item, so that it can be used to fill in
'''
subModel = NestedDictCompleteModel()
self.itemDict[ itemData ] = subModel
n = self.stringModel.rowCount()
self.stringModel.insertRows(n, 1)
self.stringModel.setData( self.stringModel.index(n), itemData)
return subModel
def filterByWords(self, words):
if len(words)<2:
return self.stringModel
if not words[0] in self.itemDict:
return QStringListModel() # Empty model
return self.itemDict[ words[0] ].filterByWords( words[1:] )
class CustomCompleter(QCompleter):
def __init__(self, *args, **kwargs):
QCompleter.__init__(self, *args, **kwargs)
self.mainModel = self.createModel(test_model_data)
self.setModel( self.mainModel.stringModel )
def createModel(self, data):
def addItems(parent, elements, t=""):
for text, children in elements:
item = parent.addItem( text )
if children:
addItems(item, children, data)
model = NestedDictCompleteModel()
addItems(model, data)
return model
def complete(self, rect, prefix):
#New Model, based on recursive model searching on ['objName', 'fieldName', 'subFieldName' etc...]
words = prefix.split('.')
model = self.mainModel.filterByWords( words )
self.setModel( model )
self.setCompletionPrefix( words[-1] ) # Filter the current model dynamically based on last word
super().complete(rect)
class CustomTextEdit(QTextEdit):
def __init__(self, idx, completer, initial_text, *args, **kwargs):
super().__init__(initial_text, *args, **kwargs)
self.completer = completer
self.idx = idx
self.heightInFocus = kwargs.get('heightInFocus', 5)
self.heightOutFocus = kwargs.get('heightInFocus', 1)
self.setHeightLines(self.heightOutFocus, anim=False)
self.animDur = 200 #100miliseconds
self.textChanged.connect(self.showSuggestions)
completer.activated.connect(self.insertCompletion)
self.blockEventFocus = False # Prevent
# Regex to match all "words.subWords" to the left/right of the string
self.reLeftAll = re.compile( r'.*?(?P<lw>[a-zA-Z][a-zA-Z0-9\.]*)$' )
self.reRightAll = re.compile( r'^(?P<rw>[a-zA-Z0-9\.]*)' )
# Regex to match single "subWord" to the left/right of the string
self.reLeft = re.compile( r'.*?(?P<lw>[a-zA-Z][a-zA-Z0-9]*)$' )
self.reRight = re.compile( r'^(?P<rw>[a-zA-Z0-9]*)' )
def isolateFocusWord(self, allTerms=True):
''' In the current text, finds the "words.subWord" around the current cursor.
Returns the word.subword string, as well the the indexes for start and end,
in the whole text
'''
text = self.toPlainText()
ti = self.textCursor().positionInBlock() # index of the cursor in text
textLeft, textRight = text[:ti], text[ti:]
if allTerms:
ml = self.reLeftAll.match(textLeft)
mr = self.reRightAll.match(textRight)
else:
ml = self.reLeft.match(textLeft)
mr = self.reRight.match(textRight)
wl = ml.group(1) if ml else ''
wr = mr.group(1) if mr else ''
return wl+wr, ti-len(wl), ti+len(wr)
def keyPressEvent(self, event):
if event.key() == Qt.Key_Return and self.completer.popup().isVisible(): # Replace suggestion when Enter is pressed
self.insertCompletion()
self.completer.popup().hide()
else:
super().keyPressEvent(event)
def insertCompletion(self):
''' Finds the current selected suggestion and replaces it in the text,
by replacing the current "subword"
'''
if not self.completer.widget() is self: return
popup_index = self.completer.popup().currentIndex()
completion = self.completer.popup().model().data(popup_index)
w, si, ei = self.isolateFocusWord(allTerms=False)
cursor = self.textCursor()
cursor.setPosition(si, cursor.MoveAnchor)#cursor.StartOfWord)
cursor.setPosition(ei, cursor.KeepAnchor) #cursor.EndOfWord, cursor.KeepAnchor)
cursor.insertText(completion)
def showSuggestions(self):
# Find the "word.subword" at the cursor
focusWord, li,ri = self.isolateFocusWord()
self.blockEventFocus = True
cursor_rect = self.cursorRect()
popup_height = self.completer.popup().sizeHintForColumn(0) # Get the height of the popup
popup_size = self.completer.popup().sizeHint()
popup_rect = QRect(cursor_rect.bottomRight(), popup_size)
popup_rect.setHeight(popup_height) # Set the height of the popup
newTop = popup_rect.top() - popup_height
popup_rect.moveTop(newTop) # Move the top of the popup
self.completer.setWidget(self)
self.completer.complete(popup_rect, focusWord ) # Show popup with suggestions
self.blockEventFocus = False
def setHeightLines(self, nlines, anim=True):
line_height = self.fontMetrics().lineSpacing()
height = (nlines+1) * line_height
if anim:
animation = QPropertyAnimation(self, b"maximumHeight")
animation.setDuration(self.animDur) # Duration of the animation in milliseconds
animation.setStartValue(self.height())
animation.setEndValue(height)
# Start the animation
animation.start()
animation2 = QPropertyAnimation(self, b"minimumHeight")
animation2.setDuration(self.animDur) # Duration of the animation in milliseconds
animation2.setStartValue(self.height())
animation2.setEndValue(height)
# Start the animation
animation2.start()
self.animations = animation, animation2
else:
self.setFixedHeight(height)
def focusInEvent(self, event):
if self.blockEventFocus: return
self.setHeightLines(self.heightInFocus)
super().focusInEvent(event)
def focusOutEvent(self, event):
if self.blockEventFocus: return
self.setHeightLines(self.heightOutFocus)
super().focusOutEvent(event)
class MainWindow(QWidget):
def __init__(self):
super().__init__()
layout = QVBoxLayout()
completer = CustomCompleter()
btn = QPushButton('Delete obj1')
# Create multiple text widgets and connect them to the same completer instance
text_edit1 = CustomTextEdit(1, completer, "Here we have obj1.field1 and also obj2")
text_edit2 = CustomTextEdit(2, completer, "For lunch we have fruit")
text_edit3 = CustomTextEdit(3, completer, "music is wonderful and obj")
layout.addWidget(QLabel("Start Typing to see object and field suggestions"))
layout.addWidget(btn)
layout.addWidget(text_edit1)
layout.addWidget(text_edit2)
layout.addWidget(text_edit3)
self.setLayout(layout)
self.setWindowTitle("Multiline Textbox with Dropdown")
#self.setGeometry(100, 100, 400, 300)
self.show()
if __name__ == "__main__":
app = QApplication(sys.argv)
window = MainWindow()
sys.exit(app.exec_())