如何使用QCompleter和QLineEdit實現支援模糊匹配的搜尋欄

overxus發表於2024-07-17

最近需要用Qt實現搜尋欄,類似於瀏覽器的搜尋欄,需要支援模糊搜尋並實時顯示匹配的選項。

接下任務後,迅速入門Qt. 本來準備魔改QComboBox,但始終處理不好使用者輸入的焦點,最終效果並不好。後來瞭解到,QLineEdit中支援QCompleterQCompleter就是用來實現補全提示的。

QCompleter支援行內補全、彈出視窗補全等多種模式;並提供了字首匹配和子串匹配兩種匹配模式,但很可惜,這兩種基本的匹配模式都不是我需要的。

所謂模糊匹配,還要支援縮寫(例如“中國”對應於“中華人民共和國”),因此需要額外實現QCompleter. QLineEdit設定QCompleter後,每當輸入欄的文字發生變化,QCompletersplitPath方法就會被呼叫,其原型為:

splitPath(self, path:str) -> list[str]

其中,引數path就是輸入欄中的字串。接下來,需要在一個待選的字串列表中選擇所有匹配項,在Qt中,字串列表可以儲存於最簡單的QStrignListModel中。

那麼如何從字串列表中篩選出匹配項呢?還需要引入一個代理類,這個類繼承自QSortFilterProxyModel. 將上述QStringListModel傳給它後,它會對每一項呼叫filterAcceptsRow方法,此方法的返回值是bool型別,表示是否接受這一項。

class FuzzyFilterProxyModel(QSortFilterProxyModel):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.split_path = ''

    def SetSplitPath(self, split_path):
        """儲存輸入欄中的字串, 在filterAcceptsRow檢查是否模糊匹配時會用到"""
        self.split_path = split_path

    def filterAcceptsRow(self, source_row:int, source_parent) -> bool:
        index = self.sourceModel().index(source_row, 0, source_parent)
        word = self.sourceModel().data(index)

        # 檢查是否模糊匹配
        for ch in self.split_path:
            if ch not in word:
                return False
        return True

提示

文中程式碼基於PySide6實現,使用PyQt或是C++的Qt差別不大。另外,在PySide6中,自帶的類方法首字母為小寫,由本人自定義的類方法首字母為大寫,請注意區分。

以上程式碼過載了filterAcceptsRow方法,在其中實現了簡單的模糊匹配功能。此外,定義了方法SetSplitPath, 用於儲存輸入欄的文字。

有了這個類,就可以自定義QCompleter了:

class FuzzyCompleter(QCompleter):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.source_model = QStringListModel([])

    def splitPath(self, path: str) -> List[str]:
        proxy_model = FuzzyFilterProxyModel()
        proxy_model.SetSplitPath(path)
        proxy_model.setSourceModel(self.source_model)
        self.setModel(proxy_model)
        return []

    def SetSourceModel(self, source_model):
        self.source_model = source_model

註釋

或許你會好奇,為什麼在splitPath中每次都需要例項化一個FuzzyFilterProxyModel,而不將其儲存為類的成員。我自己也覺得不必如此,但如果不重新設定模型,搜尋提示功能無法正常工作。(做完這個就跑路,不管那麼多了🤡)

之後,基於QLineEdit實現搜尋欄就是小菜一碟了:

class SearchBox(QLineEdit):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.items = []
        self.fuzzy_completer = FuzzyCompleter(self)
        self.setCompleter(self.fuzzy_completer)

    def SetItems(self, items:list[str]):
        self.items = items
        self.fuzzy_completer.SetSourceModel(QStringListModel(self.items))

由於我需要頻繁修改待選的字串列表,因此自定義了一個SetItems方法。以上程式碼僅供參考,根據具體需要加以調整。

完整程式碼如下(程式碼中使用了qdarktheme(pip install pyqtdarktheme)美化介面, 不想用的話可以刪掉它):

searchbox.py
import sys
from PySide6.QtWidgets import QApplication, QLineEdit, QCompleter
from PySide6.QtCore import QStringListModel, QSortFilterProxyModel
import qdarktheme


class FuzzyFilterProxyModel(QSortFilterProxyModel):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.split_path = ''
    
    def SetSplitPath(self, split_path):
        """儲存輸入欄中的字串, 在filterAcceptsRow檢查是否模糊匹配時會用到"""
        self.split_path = split_path

    def filterAcceptsRow(self, source_row: int, source_parent) -> bool:
        index = self.sourceModel().index(source_row, 0, source_parent)
        word = self.sourceModel().data(index)
        # 檢查是否模糊匹配
        for ch in self.split_path:
            if ch not in word:
                return False
        return True


class FuzzyCompleter(QCompleter):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.source_model = QStringListModel([])

    def splitPath(self, path: str) -> list[str]:
        proxy_model = FuzzyFilterProxyModel()
        proxy_model.SetSplitPath(path)
        proxy_model.setSourceModel(self.source_model)
        self.setModel(proxy_model)
        return []
    
    def SetSourceModel(self, source_model):
        self.source_model = source_model


class SearchBox(QLineEdit):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.items = []
        self.fuzzy_completer = FuzzyCompleter(self)
        self.setCompleter(self.fuzzy_completer)
    
    def SetItems(self, items:list[str]):
        self.items = items
        self.fuzzy_completer.SetSourceModel(QStringListModel(self.items))
    
    def IsValidInput(self):
        return self.text().strip() in self.items


if __name__ == '__main__':
    app = QApplication(sys.argv)  
    qdarktheme.setup_theme()

    exe = SearchBox()
    exe.setMinimumWidth(500)
    exe.SetItems(['你好', '我好', '大家好', '我們在一起', '隔壁老王', '都挺好', '好個P'])
    exe.show()
    
    sys.exit(app.exec())

最終效果如下:

相關文章