本文为记录摘抄自 ZooTool 工具集中的 ListViewLayout ,该布局可以根据父 UI 组件窗口的宽度,调整每一行所能容纳的最大的元素个数。类似flowLayout,但比flowLayout省资源。

示例代码如下:

# -*- coding:utf-8 -*-
# !/usr/bin/env python3
# Author: Mirror
# File: flowLayout
# Time: 2022-12-10 21:43
# Update: 2022-12-12 22:43
# Environment:PyCharm
# Blog: www.mirrorcg.com
# Description: listViewLayout布局示例
# =========================================
import os
import glob
import tempfile
import copy
import json
import zipfile
import imghdr
import sys
from functools import partial
from PySide2.QtGui import *
from PySide2.QtCore import *
from PySide2.QtWidgets import *

if sys.version_info[0] == 3:
    string_types = str
else:
    string_types = bytes


INFOASSET = "assetType"  # key for the .zooinfo dict and file (as json)
INFOCREATORS = "creators"  # key for the .zooinfo dict and file (as json)
INFOWEBSITES = "websites"  # key for the .zooinfo dict and file (as json)
INFOTAGS = "tags"  # key for the .zooinfo dict and file (as json)
INFODESCRIPTION = "description"  # key for the .zooinfo dict and file (as json)
INFOSAVE = "saved"  # key for the .zooinfo dict and file (as json)
INFOANIM = "animation"  # key for the .zooinfo dict and file (as json)
VERSIONKEY = "version"
GENERICVERSIONNO = "1.0.0"  # the version number of the generic file format
ASSETTYPES = ["Not Specified", "Hero Model", "Prop Model", "Background", "Background Lights", "Scene", "IBL", "Lights",
              "Shaders", "Animation", "Camera"]
ZOOINFOSUFFIX = "zooInfo"  # TODO use new constant in zoosceneconstants
QTSUPPORTEDIMAGES = ('bmp', 'gif', 'jpg', 'jpeg', 'mng', 'png', 'pbm', 'pgm', 'ppm', 'tiff', 'xbm', 'xpm', 'svg', 'tga')
DEPENDENCY_FOLDER = "fileDependencies"  # the prefix of the dependency folder for file dependencies.
ZOO_THUMBNAIL = "thumbnail"  # the name of thumbnail images minus the file extension which can be png or jpg etc
ZOOSCENE_EXT = "zooScene"  # main suffix for zooScene files
PREFERENCE_FOLDER = "preferences"
INTERFACE_FOLDER = "interface"
preference = None
DIRECTORYNAMEDEPENDENCIES = "fileDependencies"


def dpiScale(value):
    """根据当前 DPI 按值调整大小
    :param value: 默认2K int型
    :return value: 当前显示器的的大小 int型
    """
    DEFAULT_DPI = 96
    mult = max(1, float(QApplication.desktop().logicalDpiY()) / float(DEFAULT_DPI))
    return value * mult


def getTempDir():
    """返回所有操作系统上的临时目录路径"""
    return os.environ.get('TEMP', os.environ.get('TMP', os.environ.get('TMPDIR', '/tmp')))


def getFilesNoExt(directory, fileNoExtension):
    """给定一个没有扩展名的文件,找到文件名,对于查找扩展名未知的缩略图很有用,即可能是.jpg或.png
    :param directory: 用于搜索和返回文件名的目录 str
    :param fileNoExtension: 要查找的文件的名称 str
    :return fileList: 与名称匹配的文件列表,可以是空的或多个 list(str)
    """
    fileList = list()
    os.chdir(directory)
    for fileName in glob.glob("{}.*".format(fileNoExtension)):
        fileList.append(fileName)
    # TODO: Probably shouldn't be using glob, return the current path to the directory
    tempDir = getTempDir()
    os.chdir(tempDir)
    return fileList


def getImageNoExtension(directory, nameNoExtension):
    """返回目录中具有名称(不带文件扩展名)的图像列表,有助于查找“缩略图.png或缩略图.jpg”,返回找到的文件的列表
    :param directory: 用于搜索和返回文件名的目录 str
    :param nameNoExtension: 没有扩展名的文件名 str
    :return imagePathList: 与名称NoExtension匹配的图像列表 list
    """
    imagePathList = list()
    fileList = getFilesNoExt(directory, nameNoExtension)
    if not fileList:
        return
    for file in fileList:
        filename, file_extension = os.path.splitext(file)
        file_extension = file_extension.replace(".", "")
        if file_extension in QTSUPPORTEDIMAGES:
            imagePathList.append(file)
    return imagePathList


def thumbnails(directory, fileListNoExt):
    """从不带扩展名的 zooScene 列表中返回缩略图路径的列表
    :return thumbPathList: 每个 zooScene 文件一个缩略图路径列表(无扩展名)
    :rtype thumbFullPathList: 基础字符串列表
    """
    thumbFullPathList = list()
    for i, zooSceneName in enumerate(fileListNoExt):
        dependFolder = "_".join([zooSceneName, DEPENDENCY_FOLDER])
        dependFolderPath = os.path.join(directory, dependFolder)
        if not os.path.isdir(dependFolderPath):
            thumbFullPathList.append(None)
            continue
        imageList = getImageNoExtension(dependFolderPath, ZOO_THUMBNAIL)
        if imageList:  # image list is only thumbnail, take the first image, could be jpg or png
            thumbFullPathList.append(os.path.join(dependFolderPath, imageList[0]))
            continue
        thumbFullPathList.append(None)
    return thumbFullPathList


def filesByExtension(directory, extensionList):
    """列出给定文件扩展名列表的给定目录中的所有文件,扩展名应该没有句号,即 [“zooScene”, “json”, “jpg”]
    Return Example:
        ["soft_sunsetSides.zooScene", "sun_redHarsh.zooScene", "sun_warmGlow.zooScene"]
    :param directory: 用于搜索和返回文件名的目录 str
    :param extensionList: A list of extensions to search ["zooScene", "json", "jpg"]
    :type extensionList: list of basestring
    :return fileList: A list of files returned that are in the directory and match the extension list
    :rtype fileList: list()str
    """
    fileList = list()
    if not os.path.isdir(directory):  # check if directory actually exists
        return fileList  # emptyList and directory

    for ext in extensionList:
        for filePath in glob.glob(os.path.join(directory, "*.{}".format(ext))):
            fileList.append(os.path.basename(filePath))
    # TODO: Probably shouldn't be using glob, return the current path to the directory
    tempDir = getTempDir()
    os.chdir(tempDir)
    return fileList


def saveJson(data, filepath, **kws):
    """此过程将给定数据保存到 json 文件
    :param kws: Json Dumps arguments , see standard python docs
    """
    try:
        with open(filepath, 'w') as f:
            json.dump(data, f, **kws)
    except IOError:
        print("Data not saved to file {}".format(filepath))
        return False

    print("------->> file correctly saved to : {0}".format(filepath))
    return True


def ensureFolderExists(path, permissions=0o775, placeHolder=False):
    """如果该文件夹不存在,则将创建一个。由于版本控制错误而构建的函数与未提交的空文件夹,此文件夹可以生成占位符文件。
    :param path: the folderpath to check or create
    :type path: str
    :param permissions: folder permissions mode
    :type permissions: int
    :param placeHolder: if True create a placeholder text file
    :type placeHolder: bool
    :raise OSError: raise OSError if the creation of the folder fails
    """
    if not os.path.exists(path):
        try:
            print("Creating folder {} [{}]".format(path, permissions))
            os.makedirs(path, permissions)
            if placeHolder:
                placePath = os.path.join(path, "placeholder")
                if not os.path.exists(placePath):
                    with open(placePath, "wt") as fh:
                        fh.write("Automatically Generated placeHolder file.")
                        fh.write("The reason why this file exists is due to source control system's which do not "
                                 "handle empty folders.")
        except OSError as e:
            # more less work if network race conditions(joy!)
            raise


def loadJson(filePath):
    """
    此过程加载并返回 json 文件的数据
    :return type{dict}: the content of the file
    """
    # load our file
    try:
        with loadFile(filePath) as f:
            data = json.load(f)
    except Exception as er:
        print("file ({}) not loaded".format(filePath))
        raise er
    # return the files data
    return data


def getFileDependenciesList(zooSceneFullPath, ignoreThumbnail=False):
    """检索依赖目录 目录名依赖项中所有文件的列表,文件没有完整路径,因此也会返回目录路径,文件是 [“文件名.abc”, “文件名.zooInfo”] 等
    :param zooSceneFullPath: the full path to the file usually .zooscene but can be any extension
    :type zooSceneFullPath: str
    :param ignoreThumbnail: ignores the files called thumbnail.* useful for renaming
    :type ignoreThumbnail: str
    :return fileDependencyList: list of short name files found in the subdirectory DIRECTORYNAMEDEPENDENCIES
    :rtype fileDependencyList: list
    :return fullDirPath: the full path of the sub directory DIRECTORYNAMEDEPENDENCIES
    :rtype fullDirPath: str
    """
    fileDependencyList = list()
    zooSceneFileName = os.path.basename(zooSceneFullPath)
    directoryPath = os.path.dirname(zooSceneFullPath)
    fileNameNoExt = os.path.splitext(zooSceneFileName)[0]
    newDirectoryName = "_".join([fileNameNoExt, DIRECTORYNAMEDEPENDENCIES])
    fullDirPath = os.path.join(directoryPath, newDirectoryName)
    if not os.path.exists(fullDirPath):  # doesn't already exist
        return fileDependencyList, ""  # return empty as directory doesn't exist
    os.chdir(fullDirPath)
    for fileName in glob.glob("{}.*".format(fileNameNoExt)):
        fileDependencyList.append(fileName)
    if not ignoreThumbnail:
        for fileName in glob.glob("thumbnail.*".format(fileNameNoExt)):
            fileDependencyList.append(fileName)
    #  return os.chdir to the temp dir as it gets in the way with file permissions for later renames!
    os.chdir(tempfile.gettempdir())
    return fileDependencyList, fullDirPath


def getSingleFileFromZooScene(zooSceneFullPath, fileExtension):
    """返回文件的名称(如果存在),扩展名为 zooSceneFullPath 中的 .abc
    获取与 .zooScene 文件关联的子目录中的所有文件,并筛选文件类型,支持返回一个文件,第一个文件它发现不适合纹理
    :param zooSceneFullPath:  the full path of the .zooScene file to be saved
    :type zooSceneFullPath: str
    :param fileExtension:  the file extension to find no ".", so alembic is "abc"
    :type fileExtension: str
    :return extFileName: the filename (no directory) of the extension given > for example "myFileName.abc"
    :rtype extFileName: str
    """
    extFileName = ""
    fileList, directory = getFileDependenciesList(zooSceneFullPath)
    if not directory:
        return extFileName
    for fileName in fileList:  # cycle through the files and find if a match with the extension
        if fileName.endswith(fileExtension):
            return os.path.join(directory, fileName)
    return extFileName


def loadFile(filepath):
    if filepath.endswith(".zip"):
        with zipfile.ZipFile(filepath, 'r') as f:
            yield f
        return
    elif ".zip" in filepath:
        # load from zipfile
        zippath, relativefilePath = filepath.split(".zip")
        zipPath = zippath + ".zip"

        with zipfile.ZipFile(zipPath, 'r') as zip:
            path = relativefilePath.replace("\\", "/").lstrip("/")
            for i in iter(zip.namelist()):
                if path == i:
                    yield zip.open(i)
                    break

        return
    with open(filepath) as f:
        yield f


def createTagInfoDict(assetType, creator, website, tags, description, saveInfo, animInfo):
    """为zooInfo 文件创建一个字典
    :param assetType: the information about asset type, model, scene, lights, shaders, etc
    :type assetType: str
    :param creator: the information about creator/s
    :type creator: str
    :param website: the information about the creators website links
    :type website: str
    :param tags: the tag information
    :type tags: str
    :param description: the full description
    :type description: str
    :param saveInfo: the file information saved as a list ["alembic", "generic lights"] etc
    :type saveInfo: list
    :param animInfo: the animation information of the file "0 100" or "" or None if none
    :type animInfo: str
    :return zooInfoDict: the dict containing all information including the file version number
    :rtype zooInfoDict: str
    """
    zooInfoDict = {INFOASSET: assetType,
                   INFOCREATORS: creator,
                   INFOWEBSITES: website,
                   INFOTAGS: tags,
                   INFODESCRIPTION: description,
                   INFOSAVE: saveInfo,
                   INFOANIM: animInfo,
                   VERSIONKEY: GENERICVERSIONNO}
    return zooInfoDict


def getZooInfoFromFile(zooSceneFullPath, message=True):
    """从 .zooScene 获取其他文件,例如从磁盘上的文件中获取 .zooInfo
    :param zooSceneFullPath: the full path of the zooScene file, this will save out as another file zooInfo
    :type zooSceneFullPath: str
    :return zooInfoDict: the dictionary with all info information, if None the file wasn't found
    :rtype zooInfoDict: dict
    :return fileFound: was the zooInfo file found?
    :rtype fileFound: bool
    """
    zooInfoFileFullPath = getSingleFileFromZooScene(zooSceneFullPath, ZOOINFOSUFFIX)
    if not os.path.exists(zooInfoFileFullPath):  # doesn't exist
        if message:
            om2.MGlobal.displayWarning("ZooInfo File Not Found")
        fileFound = False
        return createTagInfoDict(ASSETTYPES[0], "", "", "", "", "", ""), fileFound  # return the empty dict as no file found
    fileFound = True
    return loadJson(zooInfoFileFullPath), fileFound  # returns zooInfoDict


def createEmptyInfoDict():
    """创建一个空的 .zooInfo 字典"""
    infoDict = {u"assetType": u"",
                u"animation": None,
                u"description": u"",
                u"version": u"1.0.0",
                u"tags": u"",
                u"creators": u"",
                u"saved": str(list()),
                u"websites": u""
                }
    return infoDict


def infoDictionaries(zooSceneNameList, directory):
    """ 返回每个 .zooScene 文件的信息字典列表。这些词典包含作者、标签、描述等信息
    :return infoDictList: 每个 .zooScene 文件的信息字典列表
    :rtype infoDictList: list of dict
    """
    infoDictList = list()
    if not zooSceneNameList:
        return dict()
    for zooSceneFile in zooSceneNameList:
        lightPresetFullPath = os.path.join(directory, zooSceneFile)
        zooInfoDict, fileFound = getZooInfoFromFile(lightPresetFullPath, message=False)
        if fileFound:
            infoDictList.append(zooInfoDict)
        else:
            infoDictList.append(createEmptyInfoDict())
    return infoDictList


def isImage(path):
    try:
        return imghdr.what(path) is not None or path.split(os.extsep)[-1] in QTSUPPORTEDIMAGES
    except IOError:
        return False


class ThumbnailDelegate(QStyledItemDelegate):
    def __init__(self, parent):
        super(ThumbnailDelegate, self).__init__(parent)

    def sizeHint(self, option, index):
        return index.model().itemFromIndex(index).sizeHint()

    def paint(self, painter, options, index):
        index.model().itemFromIndex(index).paint(painter, options, index)


class ThumbListView(QListView):
    contextMenuRequested = Signal(list, object)
    requestZoom = Signal(object, float)
    # QModelIndex, Treeitem
    requestDoubleClick = Signal(object, object)
    zoomAmount = 1
    defaultMinIconSize = 20
    defaultMaxIconSize = 512
    defaultIconSize = QSize(256, 256)
    stateChanged = Signal()

    WheelEvent = 1
    EnterEvent = 2
    CalcInitialEvent = 3
    CalcEvent = 4
    VerticalSliderReleasedEvent = 5

    def __init__(self, parent=None, delegate=None, iconSize=defaultIconSize, uniformItems=False):

        super(ThumbListView, self).__init__(parent=parent)
        self.setContextMenuPolicy(Qt.CustomContextMenu)
        self.customContextMenuRequested.connect(self._contextMenu)
        self.autoScale = True
        self.defaultSize = None
        self.initialIconSize = None
        self.columnQueue = 0
        self.columnOffset = 1

        self.zoomable = True
        self._iconSize = QSize()
        self.setIconSize(iconSize or self.defaultIconSize)
        self.initUi()

        self.maxColumns = 8
        self.setVerticalScrollBar(QScrollBar(self))
        self.connections()

        self._delegate = delegate(self) if delegate is not None else ThumbnailDelegate(self)
        self.setItemDelegate(self._delegate)
        self.setUpdatesEnabled(True)
        self._uniformItemSizes = uniformItems
        self.setUniformItemSizes(uniformItems)

    def initUi(self):
        self.setMouseTracking(True)
        self.setSelectionRectVisible(True)
        self.setViewMode(QListView.IconMode)
        self.setResizeMode(QListView.Adjust)
        self.setSelectionMode(QListView.SingleSelection)
        self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)

        self.setDragEnabled(False)
        self.setAcceptDrops(False)

    def _contextMenu(self, position):
        model = self.model()
        if model is None:
            return
        menu = QMenu(self)
        selectionModel = self.selectionModel()
        selection = [model.itemFromIndex(index) for index in selectionModel.selectedIndexes()]
        self.contextMenuRequested.emit(selection, menu)
        menu.exec_(self.viewport().mapToGlobal(position))

    def setUniformItemSizes(self, enable):
        self._uniformItemSizes = enable
        if self.model():
            self.model().setUniformItemSizes(enable)

    def setIconSize(self, size):
        self._iconSize = size
        super(ThumbListView, self).setIconSize(size)

    def connections(self):
        self.verticalScrollBar().sliderMoved.connect(self.verticalSliderMoved)
        self.verticalScrollBar().sliderReleased.connect(self.verticalSliderReleased)
        self.clicked.connect(lambda: self.stateChanged.emit())
        self.activated.connect(lambda: self.stateChanged.emit())
        # self.entered.connect(lambda: self.stateChanged.emit())

    def wheelEvent(self, event):
        """ 重写以处理缩放 listview.
        :type event: :class:`QEvent`
        """
        modifiers = event.modifiers()
        if self.zoomable and modifiers == Qt.ControlModifier:
            if event.delta() > 0:
                self.columnOffset -= 1
            else:
                self.columnOffset += 1
                self.columnOffset = min(self.columnOffset, self.maxColumns)

            size = self.widgetSize()
            index = self.indexAt(event.pos())

            # if its an invalid index, find closest instead
            if not index.isValid():
                index = self.closestIndex(event.pos())

            self.calcResize(size)
            QTimer.singleShot(0, lambda: self.scrollTo(index))
            event.accept()
            self.stateChanged.emit()
            return
        super(ThumbListView, self).wheelEvent(event)
        self.loadVisibleIcons()
        self.stateChanged.emit()

    def state(self):
        """ Returns useful settings to copy from one list view behaviour to another

        :return:
        :rtype:
        """
        selectedIndex = self.selectionModel().currentIndex().row()
        ret = {"sliderPos": self.verticalScrollBar().value(),
               "sliderMin": self.verticalScrollBar().minimum(),
               "sliderMax": self.verticalScrollBar().maximum(),
               "selected": selectedIndex,
               "columns": self.columnOffset,
               "zoomAmount": self.zoomAmount,
               "iconSize": self._iconSize,
               "initialIconSize": self.initialIconSize,
               "defaultSize": self.defaultSize,
               "fixedSize": self.parentWidget().minimumSize()
               }

        return ret

    def setState(self, state):
        """ Set the state of the listview with the new settings provided from ThumbListView.state()

        :param state:
        :type state:
        :return:
        :rtype:
        """
        self.columnOffset = state['columns']
        self._iconSize = state['iconSize']
        self.zoomAmount = state['zoomAmount']
        self.defaultSize = state['defaultSize']
        self.initialIconSize = state['initialIconSize']

        fixedSize = state['fixedSize']

        if fixedSize.width() != 0:
            self.parentWidget().setFixedWidth(fixedSize.width())
        if fixedSize.height() != 0:
            self.parentWidget().setFixedHeight(fixedSize.height())

        self.calcResize(self.widgetSize())
        self.verticalScrollBar().setMinimum(state['sliderMin'])
        self.verticalScrollBar().setMaximum(state['sliderMax'])
        self.verticalScrollBar().setValue(state['sliderPos'])

        self.loadVisibleIcons()

        if state['selected'] != -1:
            QTimer.singleShot(0, lambda: self.delaySelect(state['selected']))

    def delaySelect(self, sel):
        """ Select index
        :param sel:
        :type sel:
        :return:
        :rtype:
        """
        autoScroll = self.hasAutoScroll()
        self.setAutoScroll(False)
        self.selectionModel().setCurrentIndex(self.model().index(sel, 0),
                                              QItemSelectionModel.ClearAndSelect)
        self.setAutoScroll(autoScroll)

    def closestIndex(self, pos):
        """ Get closest index based on pos
        :param pos:
        :type pos:
        :return:
        :rtype:
        """
        maxDist = -1
        closest = None
        for index in self.visibleItems():
            c = self.visualRect(index)
            if c.top() <= pos.y() <= c.bottom():  # Only choose the ones from the same row
                dist = pos.x() - c.center().x()
                if maxDist == -1 or dist < maxDist:
                    closest = index
                    maxDist = dist
        return closest

    def loadVisibleIcons(self):
        """ Loads visible icons in the view if they have not been loaded yet
        :return:
        :rtype:
        """

        for index in self.visibleItems(pre=5, post=5):
            try:
                treeItem = self.model().items[index.row()]
                item = treeItem.item()
                if not item.iconLoaded():
                    self.model().threadPool.start(item.iconThread)

            except IndexError:
                pass  # this should possibly be handled

    def filter(self, text, tag=None):
        filterList = self.model().filterList(text, tag)
        self.loadVisibleIcons()

    def verticalSliderReleased(self):
        self.stateChanged.emit()

    def verticalSliderMoved(self, pos):
        """ On vertical slider moved, reload the visible icons

        :return:
        :rtype:
        """
        self.loadVisibleIcons()

    def model(self):
        """ 获取 ListView model
        :return:
        :rtype: FileViewModel
        """
        return super(ThumbListView, self).model()

    def visibleItems(self, pre=0, post=0):
        """ 获取可见项。如果要在开头和结尾添加额外的索引,请将 extra 设置为 1 或更多。它只会返回有效索引。
        :param pre: Add extra items behind the currently visible items
        :type pre: int
        :param post: Add extra items after the currently visible items
        :type post: int
        :return: List of indices that are visible plus the pre and post. It only returns valid indices
        :rtype: list of QModelIndex
        """
        firstIndex = self.indexAt(QPoint(0, 0))
        viewportRect = self.viewport().rect()
        items = list()
        after = post

        i = -pre  # We want to check indices behind the firstIndex
        while True:
            sibling = firstIndex.sibling(firstIndex.row() + i, 0)
            # The index is visually shown in the viewport?
            if sibling.isValid() and viewportRect.intersects(self.visualRect(sibling)):
                items.append(sibling)
            elif sibling.isValid() and i < 0:  # If it's indices behind the firstIndex
                items.append(sibling)
            elif sibling.isValid() and after > 0:  # We want some extra indices at the end so keep going
                after -= 1
                items.append(sibling)
            elif i < 0 or self.isIndexHidden(sibling):  # We want to keep going even if the behind siblings are invalid,
                # Or hidden by search
                i += 1
                continue

            else:
                break  # Either it is invalid (reached the end) or we've gone outside the screen
            i += 1

        return items

    def scrollTo(self, index, hint=None):
        """ 覆盖默认的scrollTo并使用我们自己的
        :param index:
        :type index:
        :return:
        :rtype:
        """
        if hint is None:
            itemRect = self.rectForIndex(index)
            vbar = self.verticalScrollBar()
            mPos = self.mapFromGlobal(QCursor().pos())  # Pos of mouse in listview widget
            newPos = (float(itemRect.y()) / float(self.contentsSize().height())) * \
                     (float(self.contentsSize().height())) - \
                     mPos.y() + \
                     (itemRect.height() * 0.5)  # maybe better if this is the mousePosition relative to the item instead
            vbar.setValue(newPos)
        else:
            super(ThumbListView, self).scrollTo(index, hint)

        self.loadVisibleIcons()

    def widgetSize(self):
        """ 不带垂直滚动条的大小
        :return:
        :rtype:
        """
        size = QSize(self.size())
        size -= QSize(self.verticalScrollBar().size().width(), 0)
        return size

    def _calculateZoom(self, delta):
        """ 在通过 'setZoomAmount' 将其应用于图标之前计算缩放系数
        :param delta: the delta value
        :type delta: float
        :return: the converted delta to zoom factor
        :rtype: float
        """
        inFactor = 1.15
        outFactor = 1 / inFactor

        if delta > 0:
            zoomFactor = inFactor
        else:
            zoomFactor = outFactor
        self.zoomAmount = zoomFactor
        return zoomFactor

    def setZoomAmount(self, value):
        """ 通过采用视图图标大小()*值来设置此视图的缩放量
        :param value:
        :type value:
        :return:
        :rtype:
        """
        currentSize = QSize(self.initialIconSize)
        newSize = currentSize.width() * value
        if newSize < self.defaultMinIconSize:
            return
        currentSize.setWidth(newSize)
        currentSize.setHeight(newSize)
        self.requestZoom.emit(newSize, self.zoomAmount)
        self.setIconSize(currentSize)

    def mouseDoubleClickEvent(self, event):
        if event.button() == Qt.LeftButton:
            index = self.currentIndex()
            model = self.model()
            if model is not None:
                item = model.itemFromIndex(index)
                model.doubleClickEvent(index, item)
                self.requestDoubleClick.emit(index, item)

    def resizeEvent(self, event):
        self.calcResize(event.size())
        super(ThumbListView, self).resizeEvent(event)
        self.loadVisibleIcons()

    def setColumns(self, col):
        """ 根据小组件的当前大小设置列数
        :param col:
        :type col:
        :return:
        :rtype:
        """

        self.setIconSize(self.defaultIconSize)

        # Sets columns on next calcResize
        self.columnQueue = col

        # Change the default size, but only if it has been already set
        if self.defaultSize is not None:
            self.defaultSize = self.widgetSize()
            self.calcResize(self.defaultSize)

    def calcResize(self, size):
        """ 计算项目的大小调整
        :param size:
        :type size:
        :return:
        :rtype:
        """
        if self.defaultSize is None:
            # Initialize values
            self.defaultSize = size
            self.initialIconSize = QSize(self._iconSize)
            self.stateChanged.emit()
            QTimer.singleShot(0, self.refresh)
            return

        # Exclude the verticalScrollbar space
        if not self.verticalScrollBar().isVisible():
            size -= QSize(self.verticalScrollBar().size().width(), 0)

        size -= QSize(dpiScale(2), 0)

        # Calculate the number of columns
        iconWidth = (self.initialIconSize * self.zoomAmount).width() + self.spacing()
        calcColumns = int(size.width() / iconWidth)  # calculate number of columns and round it down
        columns = calcColumns + self.columnOffset

        # setColumn() was run so set the columns by changing the columnOffset
        if self.columnQueue != 0:
            diff = self.columnQueue - calcColumns
            columns = self.columnQueue
            self.columnOffset = diff
            self.columnQueue = 0

        columns = max(columns, 1)  # atleast 1 column

        # Set columnOffset maximum and minimum

        # Calculate the new ratio to resize the icons to
        ratio = float(iconWidth * columns) / float(size.width())
        self.setZoomAmount(1 / ratio)
        self.stateChanged.emit()

    def refresh(self):
        """ 刷新,以便图标正确显示
        :return:
        :rtype:
        """
        QCoreApplication.processEvents()
        size = self.size() - QSize(self.verticalScrollBar().size().width(), 0)
        self.resizeEvent(QResizeEvent(size, size))
        self.model().layoutChanged.emit()
        if not self.updatesEnabled():
            self.setUpdatesEnabled(True)

    def setModel(self, model):
        model.setUniformItemSizes(self._uniformItemSizes)
        return super(ThumbListView, self).setModel(model)


class ThumbnailViewWidget(QWidget):
    """用于查看缩略图的主要小部件,它运行在自定义QStandardItemModel上"""
    requestSelectionChanged = Signal(object, object)
    def __init__(self, parent=None, listDelegate=None, listView=ThumbListView, columns=None, iconSize=None,
                 fixedWidth=None, fixedHeight=None, uniformIcons=False):
        """
        :param parent: the parent widget
        :type parent: QWidget
        :param listDelegate:
        :type listDelegate:
        :param listView:
        :type listView:
        :param columns: The number of square image columns, will vary for non-square images, overrides iconSize
        :type columns: int
        :param iconSize: Set the icon size in pixels, will be overridden by columns
        :type iconSize: QSize
        :param fixedWidth: The fixed width of the widget in pixels, dpi handled
        :type fixedWidth: int
        :param fixedHeight: the fixed height of the widget in pixels, dpi handled
        :type fixedHeight: int
        :param uniformIcons: Will keep the icons square, images will be clipped if True, non square if False
        :type uniformIcons: bool
        """
        super(ThumbnailViewWidget, self).__init__(parent=parent)
        self.qModel = None
        self.listDelegate = listDelegate
        self.listViewClass = listView  # type: type(ThumbListView)
        self.uniformIcons = uniformIcons

        self.initUi()
        self.autoResizeItems = True
        self.pagination = True

        if iconSize is not None:
            self.setIconSize(iconSize)
        if columns:
            self.setColumns(columns)
        if fixedHeight:
            self.setFixedHeight(dpiScale(fixedHeight))
        if fixedWidth:
            self.setFixedWidth(dpiScale(fixedWidth))

    def initUi(self):
        layout = QVBoxLayout(self)
        self.setLayout(layout)
        self.listView = self.listViewClass(parent=self, delegate=self.listDelegate, uniformItems=self.uniformIcons)
        layout.addWidget(self.listView)
        self.listView.verticalScrollBar().valueChanged.connect(self.paginationLoadNextItems)
        self.listView.contextMenuRequested.connect(self.contextMenu)

        layout.setContentsMargins(0, 0, 0, 0)
        self.listView.setSpacing(dpiScale(0))

    def setModel(self, model):
        self.listView.setModel(model)
        model.refreshRequested.connect(self.listView.loadVisibleIcons)
        model.view = self

        self.qModel = model
        if self.listView.selectionModel():
            self.listView.selectionModel().selectionChanged.connect(self.onSelectionChanged)

    def refreshListView(self):
        """ 刷新列表视图,确保图标正确调整大小"""
        self.listView.refresh()

    def invisibleRootItem(self):
        if self.qModel:
            return self.qModel.invisibleRootItem()

    def iconSize(self):
        return self.listView.iconSize()

    def setIconSize(self, size):
        if size == self.listView.iconSize():
            return
        maxSize = self.listView.defaultMaxIconSize
        minSize = self.listView.defaultMinIconSize
        width = size.width()
        height = size.height()
        if width > maxSize or height > maxSize:
            size = QSize(maxSize, maxSize)
        elif width < minSize or height < minSize:
            size = QSize(minSize, minSize)
        self.listView.setIconSize(size)

    def filter(self, text, tag=None):
        self.listView.filter(text, tag)

    def setColumns(self, col):
        """ 将列重置为默认值"""
        self.listView.setColumns(col)

    def setIconMinMax(self, size):
        """ 设置最小和最大图标大小
        :param size: 图标大小的最小值和最大值
        :type size: tuple(int, int)
        """
        minSize = size[0]
        maxSize = size[1]
        self.listView.defaultMinIconSize = minSize
        self.listView.defaultMaxIconSize = maxSize
        currentSize = self.listView.iconSize()
        width = currentSize.width()
        height = currentSize.height()
        if width > maxSize or height > maxSize:
            size = QSize(maxSize, maxSize)
            self.listView.setIconSize(size)
        elif width < minSize or height > minSize:
            size = QSize(minSize, minSize)
            self.listView.setIconSize(size)

    def paginationLoadNextItems(self):
        """当垂直滑块达到最大值时调用模型 loadData 方法的简单方法,对加载模型的下一页数据很有用。"""
        if not self.pagination:
            return
        model = self.listView.model()
        if model is None:
            return

        vbar = self.listView.verticalScrollBar()
        sliderMax = vbar.maximum()
        sliderPos = vbar.sliderPosition()

        if sliderPos == sliderMax:
            indexes = self.listView.selectionModel().selection().indexes()
            model.loadData()
            # Reselect selection
            self.listView.setAutoScroll(False)
            [self.listView.selectionModel().setCurrentIndex(index, QItemSelectionModel.SelectCurrent) for index
             in indexes]
            self.listView.setAutoScroll(True)

    def contextMenu(self, items, menu):
        pass

    def onSelectionChanged(self):
        index = self.listView.currentIndex()
        model = self.listView.model()
        if model is not None:
            item = model.itemFromIndex(index)
            model.onSelectionChanged(index, item)
            self.requestSelectionChanged.emit(index, item)


class SettingObject(dict):
    """设置类,用于封装给定设置的 json 数据"""
    def __init__(self, root, relativePath=None, **kwargs):

        relativePath = relativePath
        if not relativePath.suffix:
            relativePath = relativePath.with_suffix(".json")
        kwargs["relativePath"] = relativePath
        kwargs["root"] = root
        super(SettingObject, self).__init__(**kwargs)

    def rootPath(self):
        if self.root:
            return self.root
        return

    def path(self):
        return self.root / self["relativePath"]

    def isValid(self):
        if self.root is None:
            return False
        elif (self.root / self.relativePath).exists():
            return True
        return False

    def __repr__(self):
        return "<{}> root: {}, path: {}".format(self.__class__.__name__, self.root, self.relativePath)

    def __cmp__(self, other):
        return self.name == other and self.version == other.version

    def __getattr__(self, item):
        try:
            return self[item]
        except KeyError:
            return super(SettingObject, self).__getattribute__(item)

    def __setattr__(self, key, value):
        self[key] = value

    def save(self, indent=False, sort=False):
        """将文件作为 json 保存到磁盘
        :param indent: 如果为 True,则很好地格式化 json(缩进 = 2)
        :type indent: bool
        :return fullPath: 保存的 .json 文件的完整路径
        :rtype fullPath: str
        """
        root = self.root

        if not root:
            return

        fullPath = root / self.relativePath
        ensureFolderExists(str(fullPath.parent))
        output = copy.deepcopy(self)
        del output["root"]
        del output["relativePath"]
        exts = fullPath.suffix
        if not exts:
            fullPath = fullPath.with_suffix("json")
        if not indent:
            saveJson(output, str(fullPath), sort_keys=sort)
        else:
            saveJson(output, str(fullPath), indent=2, sort_keys=sort)

        return self.path()


class ItemSignals(QObject):
    """
    定义正在运行的工作线程中可用的信号。
    支持的信号包括:
    完成:无数据
    错误:`tuple` (exctype, value, traceback.format_exc() )
    处理结果:`object` 从处理中返回的数据,任何东西
    处理进度:`int` 指示进度百分比
    """
    updated = Signal(object)
    finished = Signal()
    error = Signal(tuple)
    result = Signal(object)
    progress = Signal(int)


class TreeItem(QStandardItem):
    backgroundColor = QColor(70, 70, 80)
    backgroundColorSelected = QColor(50, 180, 240)
    backgroundColorHover = QColor(50, 180, 150)
    textColorSelected = QColor(255, 255, 255)
    textColor = QColor(255, 255, 255)
    textBGColor = QColor(0, 0, 0)
    backgroundBrush = QBrush(backgroundColor)
    backgroundColorSelectedBrush = QBrush(backgroundColorSelected)
    backgroundColorHoverBrush = QBrush(backgroundColorHover)
    borderColorSelected = QColor(0, 0, 0)
    borderColorHover = QColor(0, 0, 0)
    borderColor = QColor(0, 0, 0)
    backgroundColorIcon = QColor(50, 50, 50)

    def __init__(self, item, parent=None, themePref=None, squareIcon=False):
        super(TreeItem, self).__init__(parent=parent)
        self.currentTheme = ""
        self.padding = 0
        self.textHeight = 11
        self.borderWidth = 1
        self.textPaddingH = 7
        self.textPaddingV = 2

        self.showText = True
        self._item = item
        self._pixmap = None
        self.iconSize = QSize(256, 256)
        self.loaderThread = ThreadedIcon(item.iconPath)
        self.setEditable(False)
        self.aspectRatio = Qt.KeepAspectRatioByExpanding

        self.themePref = themePref
        self.squareIcon = squareIcon

        self.setBorderWidth(1)

        if themePref is not None:
            self.updateTheme()

    def updateTheme(self):
        self.currentTheme = self.themePref.currentTheme()
        self.textHeight = self.themePref.DEFAULT_FONTSIZE
        self.borderWidth = self.themePref.ONE_PIXEL
        self.textPaddingH = dpiScale(7)
        self.textPaddingV = dpiScale(3)
        self.backgroundColorSelected = QColor(*self.themePref.BROWSER_SELECTED_COLOR)
        self.backgroundColor = QColor(*self.themePref.BROWSER_BG_COLOR)
        self.backgroundBrush = QBrush(self.backgroundColor)
        self.backgroundColorHover = self.backgroundColor
        self.backgroundColorHoverBrush = QBrush(self.backgroundColorHover)
        self.backgroundColorSelectedBrush = QBrush(self.backgroundColorSelected)
        self.backgroundColorIcon = QColor(*self.themePref.BROWSER_ICON_BG_COLOR)
        self.borderColor = QColor(self.backgroundColor)

        self.textColorSelected = QColor(*self.themePref.TBL_TREE_ACT_TEXT_COLOR)
        self.borderColorSelected = QColor(*self.themePref.BROWSER_SELECTED_COLOR)
        self.borderColorHover = QColor(*self.themePref.BROWSER_BORDER_HOVER_COLOR)
        self.textColor = QColor(*self.themePref.BROWSER_TEXT_COLOR)

        self.textBGColor = QColor(*self.themePref.BROWSER_TEXT_BG_COLOR)

    def item(self):
        return self._item

    def applyFromImage(self, image):
        pixmap = QPixmap()
        pixmap = pixmap.fromImage(image)
        self._pixmap = pixmap
        self.model().dataChanged.emit(self.index(), self.index())

    def setIconPath(self, iconPath):
        self._item.iconPath = iconPath
        self._pixmap = QPixmap(iconPath)

    def pixmap(self):
        """
        :return:
        :rtype: QPixmap
        """
        if not self._pixmap.isNull():
            return self._pixmap
        elif not os.path.exists(self._item.iconPath):
            return QPixmap()
        return self._pixmap

    def toolTip(self):
        return self._item.description()

    def isEditable(self, *args, **kwargs):
        return False

    def sizeHint(self):
        # todo: could be done better
        sizeHint = self.model().view.iconSize()
        if self._pixmap:
            pxSize = self._pixmap.rect().size()
        else:
            pxSize = QSize(1, 1)

        size = min(sizeHint.height(), sizeHint.width())
        pxSize = QSize(128, 128) if pxSize == QSize(0, 0) else pxSize
        if self.squareIcon:
            aspectRatio = 1
        else:
            aspectRatio = float(pxSize.width()) / float(pxSize.height())
            if aspectRatio <= 1:
                sizeHint.setWidth(size * aspectRatio)
            else:
                sizeHint.setWidth(size * aspectRatio)

        sizeHint.setHeight(size + 1)

        if self.showText:
            sizeHint.setHeight(sizeHint.height() + self.textHeight + self.textPaddingV * 2)

        return sizeHint

    def font(self, index):
        return QFont("Roboto")

    def textAlignment(self, index):
        return Qt.AlignLeft | Qt.AlignBottom

    def iconAlignment(self, index):
        return Qt.AlignLeft | Qt.AlignVCenter

    def removeRow(self, item):
        if item.loaderThread.isRunning:
            item.loaderThread.wait()
        return super(TreeItem, self).removeRow(item)

    def removeRows(self, items):
        for item in items:
            if item.loaderThread.isRunning:
                item.loaderThread.wait()
        return super(TreeItem, self).removeRows(items)

    def isSelected(self, option):
        return option.state & QStyle.State_Selected

    def isMouseOver(self, option):
        return option.state & QStyle.State_MouseOver

    def setBorderWidth(self, width):
        self.borderWidth = dpiScale(width)

    def paint(self, painter, option, index):
        painter.save()
        self._paintBackground(painter, option, index)
        if self.showText:
            self._paintText(painter, option, index)
        if self._pixmap is not None:
            self._paintIcon(painter, option, index)
        painter.restore()

    def _paintIcon(self, painter, option, index):
        """
        :param painter:
        :type painter:  QPainter
        :param option:
        :type option:
        :param index:
        :type index:
        :return:
        :rtype:
        """
        rect = self.iconRect(option)
        pixmap = self.pixmap()  # type: QPixmap
        if pixmap.isNull():
            return
        pixmap = pixmap.scaled(
            rect.width() - self.borderWidth * 2,
            rect.height() - self.borderWidth * 2,
            self.aspectRatio,
            Qt.SmoothTransformation,
        )

        pixmapRect = QRect(rect)
        pixmapRect.setWidth(pixmap.width())
        pixmapRect.setHeight(pixmap.height())
        aspectRatio = float(pixmap.width()) / float(pixmap.height())

        iconAlign = self.iconAlignment(None)

        if iconAlign & Qt.AlignHCenter == Qt.AlignHCenter:
            x = float(rect.width() - pixmap.width()) * 0.5
        elif iconAlign & Qt.AlignLeft == Qt.AlignLeft:
            x = 0
        else:  # todo: set the rest of the flags
            x = float(rect.width() - pixmap.width()) * 0.5
            print("Flags not set for TreeItem._paintIcon()! x-Value")

        if iconAlign & Qt.AlignVCenter == Qt.AlignVCenter:
            y = float(rect.height() - pixmap.height()) * 0.5
        else:  # todo: set the rest of the flags
            y = float(rect.height() - pixmap.height()) * 0.5
            print("Flags not set for TreeItem._paintIcon() y-Value!  ")

        x += self.borderWidth
        pixmapRect.translate(x, y)

        # Hacky fixes to visuals =[
        if self.squareIcon:
            clippedRect = QRect(pixmapRect)
            clippedRect.setWidth(clippedRect.width() - 1)
            if clippedRect.height() <= clippedRect.width():  # Wide icons
                translate = (clippedRect.width() - clippedRect.height()) / 2
                clippedRect.setWidth(clippedRect.height() - 1)
                pixmapRect.translate(-translate, 0)
            else:  # Tall Icons
                translate = (clippedRect.height() - clippedRect.width()) / 2
                clippedRect.setHeight(clippedRect.width() + 2)
                clippedRect.setWidth(clippedRect.width())
                clippedRect.translate(0, translate)
            painter.setClipRect(clippedRect)
        else:
            if aspectRatio > 1:
                pixmapRect.setWidth(pixmapRect.width())
            elif aspectRatio >= 1:
                pixmapRect.setWidth(pixmapRect.width() - 1)
        painter.drawPixmap(pixmapRect, pixmap)

    def _paintText(self, painter, option, index):
        isSelected = self.isSelected(option)
        isMouseOver = self.isMouseOver(option)
        text = self._item.name
        color = self.textColorSelected if isSelected else self.textColor
        rect = QRect(option.rect)
        width = rect.width() - self.textPaddingH * 2
        height = rect.height()
        padding = self.padding
        x, y = padding, padding
        rect.translate(x + self.textPaddingH, y + self.textPaddingV)
        rect.setWidth(width - padding)
        rect.setHeight(height - padding - self.textPaddingV)
        font = self.font(index)
        font.setPixelSize(self.textHeight)

        align = self.textAlignment(index)
        metrics = QFontMetricsF(font)
        textWidth = metrics.width(text)
        # does the text fit? if not cut off the text
        if textWidth > rect.width() - padding:
            text = metrics.elidedText(text, Qt.ElideRight, rect.width())

        if isSelected:
            textBgColor = self.borderColorSelected
        else:
            textBgColor = self.textBGColor

        textBg = QRect(option.rect)
        textBg.setTop(textBg.top() + textBg.height() - (self.textHeight + self.textPaddingV * 2))
        textBg.translate(max(1, self.borderWidth * 0.5), max(1, self.borderWidth * 0.5) - 2)
        textBg.setWidth(textBg.width() - self.borderWidth * 2 - 1)
        textBg.setHeight(self.textHeight + self.textPaddingV)
        painter.setBrush(textBgColor)
        painter.setPen(textBgColor)
        painter.drawRect(textBg)

        pen = QPen(color)
        painter.setPen(pen)
        painter.setFont(font)
        painter.drawText(rect, align, text)

    def _paintBackground(self, painter, option, index):

        isSelected = self.isSelected(option)
        isMouseOver = self.isMouseOver(option)

        if isSelected:
            brush = self.backgroundColorSelectedBrush

            if isMouseOver:
                borderColor = self.borderColorHover
            else:
                borderColor = self.borderColorSelected

        elif isMouseOver:
            brush = self.backgroundColorHoverBrush
            borderColor = self.borderColorHover
        else:
            brush = self.backgroundBrush
            borderColor = self.borderColor
        pen = QPen(borderColor)
        pen.setJoinStyle(Qt.MiterJoin)
        pen.setWidth(self.borderWidth)
        painter.setPen(pen)

        rect = QRect(option.rect)
        rect.setWidth(rect.width() - self.borderWidth)
        rect.setHeight(rect.height() - self.borderWidth)
        rect.translate(self.borderWidth * 0.5, self.borderWidth * 0.5)

        painter.setBrush(brush)
        painter.drawRect(rect)

        # Icon background
        iconPen = QPen(self.backgroundColorIcon)
        iconPen.setWidth(0)
        iconRect = QRect(rect)
        painter.setBrush(QBrush(self.backgroundColorIcon))
        iconRect.setHeight(iconRect.height() - (self.textHeight + self.textPaddingV * 2))
        iconRect.translate(max(1, self.borderWidth * 0.5), max(1, self.borderWidth * 0.5))
        iconRect.setWidth(iconRect.width() - self.borderWidth * 2)
        painter.setPen(iconPen)
        painter.drawRect(iconRect)

    def iconRect(self, option):
        padding = self.padding
        rect = option.rect
        width = rect.width() - padding
        height = rect.height() - padding
        # deal with the text #
        if self.showText:
            height -= self.textHeight + self.textPaddingV * 2
        rect.setWidth(width)
        rect.setHeight(height)

        x = padding + (float(width - rect.width()) * 0.5)
        y = padding + (float(height - rect.height()) * 0.5)
        rect.translate(x, y)
        return rect


class ThreadedIcon(QRunnable):

    def __init__(self, iconPath, width=None, height=None, *args, **kwargs):
        super(ThreadedIcon, self).__init__(*args, **kwargs)
        self.signals = ItemSignals()
        # Add the callback to our kwargs
        kwargs['progress_callback'] = self.signals.progress

        self._path = iconPath
        self.width = width
        self.height = height
        self.placeHolderImage = QImage(50, 50, QImage.Format_ARGB32)
        self.placeHolderImage.fill(qRgb(96, 96, 96))
        self._finished = False

    def finished(self, state):
        self._finished = state
        self.signals.finished.emit()

    def isFinished(self):
        return self._finished

    @Slot()
    def run(self):
        if not self._path or self._finished:
            return
        self.signals.updated.emit(self.placeHolderImage)
        try:
            image = QImage(self._path)
        except Exception as er:
            self.signals.error.emit((er,))
            self.finished(True)
            return
        self.signals.updated.emit(image)
        self.finished(True)


class BaseItem(object):
    def __init__(self, name=None, description=None, iconPath=None):
        self.name = name or ""
        self.iconPath = iconPath or ""
        self._description = description or ""
        self.metadata = {}
        self.user = ""
        self.iconThread = None  # type: ThreadedIcon

    def description(self):
        return self._description

    def iconLoaded(self):
        if self.iconThread is not None and self.iconThread.isFinished():
            return True
        return False

    def tags(self):
        return self.metadata.get("metadata", {}).get("tags", [])

    def hasTag(self, tag):
        for i in self.tags:
            if tag in i:
                return True
        return False

    def hasAnyTags(self, tags):
        for i in tags:
            if self.hasTag(i):
                return True
        return False

    def serialize(self):
        return {"metadata": {"time": "",
                             "version": "",
                             "user": "",
                             "name": self.name,
                             "application": {"name": "",
                                             "version": ""},
                             "description": "",
                             "tags": []},
                }


class FileViewModel(QStandardItemModel):
    parentClosed = Signal(bool)
    doubleClicked = Signal(str)
    selectionChanged = Signal(str)
    refreshRequested = Signal()

    def __init__(self, view, directory=None, extensions=None, chunkCount=20, uniformIcons=False):
        """ 覆盖基本模型以处理缩略图视图小部件数据的加载,例如。特定文件/图像
        此类是附加到缩略图视图的缩略图模型的最基本形式,给定并填充有“png”、“jpg”或“jpeg”图像的目录。
        工具提示也是从文件名生成的,可以进一步覆盖此类,以便在子目录(如 .zooScene 文件)中加载自定义图像。
        :param view: The viewer to assign this model data?
        :type view: thumbwidget.ThumbListView
        :param directory: The directory full path where the .zooScenes live
        :type directory: str
        :param chunkCount: The number of images to load at a time into the ThumbnailView widget
        :type chunkCount: int
        :param extensions: The image file extensions to override, otherwise will be ["png", "jpg", "jpeg"]
        :type extensions: list of basestring
        :param uniformIcons: False keeps the images non-square.  True the icons will be square, clipped on longest axis
        :type uniformIcons: bool
        """
        super(FileViewModel, self).__init__(parent=view)
        self.view = view
        self.extensions = extensions
        self.chunkCount = chunkCount
        self.currentFilesList = []  # the files loaded in the viewer, empty while initializing
        self.infoDictList = None  # usually used for generating tooltips
        self.toolTipList = None  # list of tooltips to match each images
        self.currentImage = None  # the current image name selected (no image highlighted on creation)
        self.fileNameList = None  # each file's name with an extension
        self.fileNameListNoExt = None  # each file's name without an extension, for display
        self.directory = None
        self.themePref = None
        self.threadPool = QThreadPool.globalInstance()
        self.lastFilter = ""
        self.uniformIcons = uniformIcons
        self.dynamicLoading = True  # Dynamic icon loading

        # load the images
        if directory is not None:
            self.setDirectory(directory, True)

    def setDirectory(self, directory, refresh=True):
        """用于设置或更改目录"""
        self.directory = directory
        if refresh:
            self.refreshList()

    def refreshList(self):
        """ 如果内容已修改,则刷新图标列表,不更改根目录 """
        self.view.setUpdatesEnabled(False)
        self.clear()
        self.refreshModelData()
        self.loadData()
        self.view.setUpdatesEnabled(True)

    def _thumbnails(self, extensions=None):
        """ 获取文件缩略图的完整路径名
        在这种情况下,缩略图是文件(图像)本身
            ["C:/aPath/image01.jpg", "C:/aPath/image02.jpg"]
        如果您需要从其他位置获取缩略图,即 .zooScene 或 .hdr 文件的缩略图,请覆盖此设置
        :type extensions: 文件扩展名列表或设置为“None”以使用所有图像
        :return: List of paths to image files
        :rtype: list of basestring
        """
        results = []
        for i in self.fileNameList:
            fullPath = os.path.join(self.directory, i)
            if extensions is None and isImage(fullPath):
                results.append(fullPath)
            else:
                # todo: do extensions
                pass

        return results

    def _infoDictionaries(self):
        """重写基类,返回每个 .zooScene 文件的信息字典列表。这些词典包含作者、标签、描述等信息
        :return infoDictList: A list of info dictionaries for each .zooScene file
        :rtype infoDictList: list(dict)
        """
        return infoDictionaries(self.fileNameList, self.directory)

    def _toolTips(self):
        """重写基类将_toolTips生成为列表,由 self.infoDictList 构造而成"""
        self.toolTipList = list()
        for i, thumb in enumerate(self.currentFilesList):
            self.toolTipList.append(self._createToolTip(self.infoDictList[i], self.fileNameList[i]))

    def _createToolTip(self, infoDictSingle, fileName):
        """重写基类,从单个 zooScene 文件创建单个工具提示
        :param zooScenePath: The full path to the zooScene image
        :type zooScenePath: str
        :param infoDictSingle: The information dictionary with tag author and description information
        :type infoDictSingle: str
        :return toolTip: The tooltip description
        :rtype toolTip: str
        """
        if not infoDictSingle:  # dict is none so return file path as tooltip
            return os.path.join(self.directory, fileName)
        # breaks on some unicode so convert to plain text
        filepath = "{} ".format(os.path.join(self.directory, fileName))
        info = ""
        website = ""
        toolTip = ("{}{}{} ".format(info, filepath, website))
        return toolTip

    def clear(self):
        """清除模型中的图像和数据,通常在刷新时使用"""
        # remove any threads that haven't started yet
        self.threadPool.clear()

        while not self.threadPool.waitForDone():
            continue
        # clear any items, this is necessary to get python to GC alloc memory
        self.items = []
        self.loadedCount = 0
        self.currentFilesList = []
        super(FileViewModel, self).clear()

    def fileList(self):
        """更新self.fileNameList ,可以覆盖
        """
        if not self.extensions:
            self.extensions = ["png", "jpg", "jpeg"]
        if not isinstance(self.extensions, list):
            raise ValueError("Extensions must be list, \"{}\" type given \"{}\" ".format(type(self.extensions),
                                                                                         self.extensions))
        self.fileNameList = filesByExtension(self.directory, self.extensions)

    def filterList(self, text, tag=None):
        """ 返回在搜索中显示或隐藏的行的整数列表
        :param text:
        :type text:
        :param tag:
        :type tag: 字符串或列表
        :return:
        :rtype:
        """
        filterList = list()
        if isinstance(tag, string_types):
            tag = [tag]

        for i in range(len(self.infoDictList)):
            for t in tag:
                if t is None or t == "filename":
                    for j, fileName in enumerate(self.fileNameListNoExt):
                        if text.lower() in fileName.lower():
                            filterList.append(j)
                else:
                    if text.lower() in self.infoDictList[i][t.lower()].lower():
                        filterList.append(i)

        for i in range(len(self.fileNameListNoExt)):
            self.view.listView.setRowHidden(i, i not in filterList)  # Show row if not in filterList

        self.lastFilter = text

        return filterList

    def refreshModelData(self):
        """Needs to create/recreate the thumbnail lists, tooltips and infoDict data
        """
        self.fileList()  # updates self.fileNameList
        self.fileNameListNoExt = [os.path.splitext(preset)[0] for preset in self.fileNameList]
        self.currentFilesList = self._thumbnails()
        self.infoDictList = self._infoDictionaries()
        self._toolTips()  # generates self.toolTipList
        self.refreshRequested.emit()

    def lazyLoadFilter(self):
        """Breaks up the lists self.currentFilesList, self.fileNameList, self.toolTipList for lazy loading.
        Can be overridden, usually to choose if the display names should have extensions or not
        Default is no extensions on display names

        :return filesToLoad: The file name fullpath list (with extensions)
        :rtype filesToLoad: list of basestring
        :return namesToLoad: The name of the item, will be the label under the image, usually extension removed
        :rtype namesToLoad: list of basestring
        :return tooltipToLoad: The toolTip list to load
        :rtype tooltipToLoad: list of basestring
        """
        if len(self.currentFilesList) < self.loadedCount:
            filesToLoad = self.currentFilesList
            namesToLoad = self.fileNameListNoExt
            tooltipToLoad = self.toolTipList
        else:
            filesToLoad = self.currentFilesList[self.loadedCount: self.loadedCount + self.chunkCount]
            namesToLoad = self.fileNameListNoExt[self.loadedCount: self.loadedCount + self.chunkCount]
            tooltipToLoad = self.toolTipList[self.loadedCount: self.loadedCount + self.chunkCount]
        return filesToLoad, namesToLoad, tooltipToLoad

    def loadData(self):
        """ Overridden method that prepares the images for loading and viewing.
        Is filtered first via self.lazyLoadFilter()
        From base class documentation:
            Lazy loading happens either on first class initialization and any time the vertical bar hits the max value,
            we then grab the current the new file chunk by files[self.loadedCount: loadedCount + self.chunkCount] that
            way we are only loading a small amount at a time.
            Since this is an example of how to use the method , you can approach it in any way you wish but for each item you
            add you must initialize a item.BaseItem() or custom subclass and a item.treeItem or subclass which handles the
            qt side of the data per item
        """

        if self.lastFilter != "":  # Don't load any new data if theres a search going on
            return

        filesToLoad, namesToLoad, tooltipToLoad = self.lazyLoadFilter()
        # Load the files
        for i, f in enumerate(filesToLoad):

            if f is None:
                f = r"C:\Users\mirror\Documents\zoo_preferences\assets\model_assets\scene_lightSetup_empty_fileDependencies\thumbnail.jpg"

            workerThread = ThreadedIcon(iconPath=f)
            # create an item for the image type
            it = BaseItem(name=namesToLoad[i], iconPath=f, description=tooltipToLoad[i])
            qitem = self.createItem(item=it)
            workerThread.signals.updated.connect(partial(self.setItemIconFromImage, qitem))
            self.parentClosed.connect(workerThread.finished)

            it.iconThread = workerThread

            if self.dynamicLoading is False:
                self.threadPool.start(workerThread)
            self.loadedCount += 1

        if len(filesToLoad) > 0:
            pass

    def createItem(self, item):
        """自定义包装器 创建 ::类项的方法。TreeItem',将其添加到模型项和类 appendRow()
        :param item:
        :type item: ::class:`baseItem`
        :return:
        :rtype: ::class:`items.TreeItem`
        """
        tItem = TreeItem(item=item, themePref=self.themePref, squareIcon=self.uniformIcons)
        self.items.append(tItem)
        self.appendRow(tItem)
        return tItem

    def setUniformItemSizes(self, enabled):
        self.uniformIcons = enabled
        for it in self.items:
            it.squareIcon = enabled

    def setItemIconFromImage(self, item, image):
        """由线程调用的自定义方法
        :param item:
        :type item: :class:`TreeItem`
        :param image: The Loaded QImage
        :type image: QImage
        """
        item.applyFromImage(image)

    def doubleClickEvent(self, modelIndex, item):
        """双击项时由列表视图调用
        :param modelIndex:
        :type modelIndex: QModelIndex
        :param item:
        :type item: TreeItem
        :return self.currentImage:  The current image with it's name and file extension
        :rtype self.currentImage:  str
        """
        self.currentImage = item._item.name
        self.doubleClicked.emit(self.currentImage)
        return self.currentImage

    def onSelectionChanged(self, modelIndex, item):
        """在更改项目时由列表视图调用,例如左键单击或右键单击
        :param modelIndex:
        :type modelIndex: QModelIndex
        :param item:
        :type item: TreeItem
        """
        try:  # can error while renaming files and the change no longer exists so ignore if so
            self.currentImage = item._item.name
            self.selectionChanged.emit(self.currentImage)
            return self.currentImage
        except AttributeError:
            pass

    def closeEvent(self, event):
        """关闭模型
        :param event:
        :type event:
        """
        if self.qModel is not None:
            self.qModel.clear()
            self.qModel.parentClosed.emit(True)
        super(FileViewModel, self).closeEvent(event)


class ZooSceneViewerModel(FileViewModel):
    # Emit signals
    parentClosed = Signal(bool)
    doubleClicked = Signal(str)
    selectionChanged = Signal(str)

    def __init__(self, view, directory="", chunkCount=20, uniformIcons=False):
        """从缩略图视图小组件的目录中加载 .zooscene 模型数据,拉取依赖目录中的缩略图,并从 file.zooInfo 文件生成工具提示。
        另请参阅类 FileViewModel() 以获取更多功能和文档。其可以进一步覆盖此类以在子目录中加载自定义图像,例如Skydomes或控件
        使用 .zooScene 标签和缩略图信息。

        :param view: 查看器分配此模型数据
        :type view: qtWidget?
        :param directory: The directory full path where the .zooScenes live
        :type directory: str
        :param chunkCount: The number of images to load at a time into the ThumbnailView widget
        :type chunkCount: int
        """
        super(ZooSceneViewerModel, self).__init__(view, directory=directory, chunkCount=chunkCount,
                                                  uniformIcons=uniformIcons)

    def _thumbnails(self):
        """重写基类,从每个文件的子目录“fileName_fileDependencies”返回缩略图路径的列表:
            ["C:/aPath/scene01_fileDependencies/thumbnail.jpg", "C:/aPath/scene02_fileDependencies/thumbnail.jpg"]
        :return 当前文件列表 list(str):
        :return 缩略图列表 list(str):
        """
        return ["C:/aPath/scene01_fileDependencies/thumbnail.jpg",
                "C:/aPath/scene02_fileDependencies/thumbnail.jpg"]  # thumbnails(self.directory, self.fileNameListNoExt)

    def fileList(self):
        """
        重写基类 生成文件列表:
        [“scene01.zooScene”, “scene02.zooScene”]
        """
        self.fileNameList = ["scene01.zooScene", "scene02.zooScene"]  # filesByExtension(self.directory, [ZOOSCENE_EXT])

if __name__ == "__main__":
    app = QApplication(sys.argv)
    thumbViewer = ThumbnailViewWidget(parent=None, columns=3, fixedHeight=382, uniformIcons=True)
    thumbModel = ZooSceneViewerModel(thumbViewer, directory=r"C:\Users\mirror\Documents\zoo_preferences\assets\model_assets",
                                                   chunkCount=200,
                                                   uniformIcons=True)
    thumbViewer.setModel(thumbModel)
    thumbViewer.show()
    sys.exit(app.exec_())


示例结果:

示例展示 png