我正在用Python制作待办事项树应用程序(Earlybird),它将在树状视图中显示可检查的任务/子任务。它包括分组为没有复选框的块(例如,工作块,主页块)的更高级别的任务组。

看起来像这样:



这是我的第一个使用Python的应用程序,通常是面向对象的编程。我通常是Matlab程序员。因此,对我所做的任何水平的反馈都将非常有帮助。我打算将应用程序放置在Github上,并添加大量功能,因此现在是向其添加炸药的最佳时机。

该应用程序有两个主要类:



EarlybirdTree:核心树视图。这是QTreeView的子类,并定义了基本方法(加载和保存文件,将已编辑的项目添加到撤消堆栈等)。。

EarlybirdMainEarlybirdTree对象的简单包装。这是QMainWindow的子类,它允许用户使用菜单和工具栏与树的方法进行交互。

以下代码包括以上两个py文件以及一个Earlybird数据文件(testFile.eb)。我以json格式存储数据。为了方便起见,每个main函数还添加了一个单独的撤消堆栈视图。请注意,为防止这种情况发生,我没有包含用于添加/删除项目以及许多其他铃声的功能。

earlybirdTree.py


# -*- coding: utf-8 -*-
"""
earlybirdTree.py
    Defines the EarlyBirdTree class, a QTreeView subclass that displays a 
    custom QStandardItemModel as a simple to-do tree. The data is saved
    as a custom json file.
"""

import sys
import os
import json
from PySide import QtGui, QtCore


class StandardItemModel(QtGui.QStandardItemModel):
    '''Items will emit this signal when edited'''
    itemDataChanged = QtCore.Signal(object, object, object, object)


class StandardItem(QtGui.QStandardItem):
    ''''Subclass QStandardItem to reimplement setData to emit itemDataChanged'''
    def setData(self, newValue, role=QtCore.Qt.UserRole + 1):
        #print "setData called with role ", role  #for debugging
        if role == QtCore.Qt.EditRole:
            oldValue = self.data(role)
            QtGui.QStandardItem.setData(self, newValue, role)
            model = self.model()
            if model is not None and oldValue != newValue:
                model.itemDataChanged.emit(self, oldValue, newValue, role)
            return True
        if role == QtCore.Qt.CheckStateRole:
            oldValue = self.data(role)
            QtGui.QStandardItem.setData(self, newValue, role)            
            model = self.model()
            if model is not None and oldValue != newValue:                             
                model.itemDataChanged.emit(self, oldValue, newValue, role)
            return True
        QtGui.QStandardItem.setData(self, newValue, role)


class EarlybirdTree(QtGui.QTreeView):
    '''The earlyBird to do tree view, the core class for the application.'''

    def __init__(self, parent=None, filename = None):
        QtGui.QTreeView.__init__(self, parent=None)
        self.parent = parent
        self.filename = filename
        self.model = StandardItemModel()
        self.rootItem = self.model.invisibleRootItem()        
        self.setModel(self.model)
        self.makeConnections()
        self.undoStack = QtGui.QUndoStack(self)
        self.setStyleSheet("QTreeView::item:hover{background-color:#999966;}")  
        self.headerLabels = ["Task"]
        self.model.setHorizontalHeaderLabels(self.headerLabels) 
        if self.filename:
            self.loadEarlybirdFile(self.filename)

    def makeConnections(self):
        '''Connect all the signals-slots needed.'''
        self.model.itemDataChanged.connect(self.itemDataChangedSlot)

    def itemDataChangedSlot(self, item, oldValue, newValue, role):
        '''Slot used to push changes of existing items onto undoStack'''
        if role == QtCore.Qt.EditRole:
            command = CommandTextEdit(self, item, oldValue, newValue,
                "Text changed from '{0}' to '{1}'".format(oldValue, newValue))
            self.undoStack.push(command)
            return True
        if role == QtCore.Qt.CheckStateRole:
            command = CommandCheckStateChange(self, item, oldValue, newValue, 
                "CheckState changed from '{0}' to '{1}'".format(oldValue, newValue))
            self.undoStack.push(command)
            return True  

    def clearModel(self):
        '''Clears data from model,clearing the view, but repopulates headers/root.
        Used whenever an .eb file is loaded, or newFile method instantiated'''
        self.model.clear()
        self.model.setHorizontalHeaderLabels(self.headerLabels)
        self.rootItem = self.model.invisibleRootItem()   

    def newFile(self):
        '''Creates blank tree'''
        if not self.undoStack.isClean() and self.saveCheck():
            self.saveTodoData()
        self.filename = None
        self.clearModel()
        self.undoStack.clear()

    def closeEvent(self, event):
        '''Typically closeevent is called by a QMainWindow wrapper, 
        but sometimes we do view these guys standalone'''
        if not self.undoStack.isClean() and self.saveCheck():
            self.fileSave()
        self.close()     

    '''
    ***
    Next five methods are part of mechanics for loading .eb files
    ***
    '''
    def loadEarlybirdFile(self, filename = None):
        '''Opens todo tree file (.eb) and populates model with data.'''
        if not self.undoStack.isClean() and self.saveCheck():
            self.saveTodoData() 
        directoryName = os.path.dirname(filename) if filename else "."
        if not filename:
            filename, foo = QtGui.QFileDialog.getOpenFileName(None,
                    "Load earlybird file", directoryName, 
                    "(*.eb)")          
        if filename:
            with open(filename) as f:
                fileData = json.load(f)
            if self.populateModel(fileData, filename):
                self.expandAll()
                self.filename = filename
                self.undoStack.clear()
                return True        
        return False   

    def populateModel(self, fileData, filename):
        '''Verify that top-level items are blocks, and call methods to load data.'''
        if "taskblocks" not in fileData:
            print "Warning: Cannot load {0}.\n"\
                  "Top level must contain taskblocks.".format(filename)
            return False 
        if "tasks" in fileData:
            print "Warning: only reads taskblocks from top level.\n"\
                  "Igorning top-level tasks in {0}.".format(filename)
        taskblockList = fileData["taskblocks"]
        self.clearModel()
        return self.loadTaskblocks(taskblockList)

    def loadTaskblocks(self, taskblockList):  
        '''Load task blocks into the model'''
        for (blockNum, taskblock) in enumerate(taskblockList): 
            blockNameItem = StandardItem(taskblock["blockname"])       
            self.rootItem.appendRow(blockNameItem)
            if "tasks" in taskblock:
                taskList = taskblock["tasks"]
                self.loadTasks(taskList, blockNameItem) 
        return True      

    def loadTasks(self, taskList, parentItem):
        '''Recursively load tasks until we hit a base task (a task w/o any subtasks).'''
        for (taskNum, task) in enumerate(taskList):
            taskNameItem = StandardItem(task["name"])
            taskNameItem.setCheckable(True)
            #print "task and done", task["name"], task["done"]
            if task["done"]:
                taskNameItem.setCheckState(QtCore.Qt.Checked)           
            else:
                taskNameItem.setCheckState(QtCore.Qt.Unchecked)
            parentItem.appendRow(taskNameItem) #add children only to column 0  
            if "tasks" in task:
                subtaskList = task["tasks"]
                return self.loadTasks(subtaskList, taskNameItem) 

    '''
    ****
    Next seven methods are part of the saving mechanics
    ***
    '''
    def saveCheck(self):
        '''If the document has been changed since last clean state, ask if the user
        wants to save the changes.'''
        if QtGui.QMessageBox.question(self,
                "Earlybird save check",
                "Save unsaved changes first?",
                QtGui.QMessageBox.Yes|QtGui.QMessageBox.No) == QtGui.QMessageBox.Yes:
            return True
        else:
            return False

    def saveTodoData(self): 
        '''Save data from the tree in json format'''
        if self.filename:
            dictModel = self.modelToDict()
            with open(self.filename, 'w') as fileToWrite:
                json.dump(dictModel, fileToWrite, indent=2)
        else:
            self.saveTodoDataAs()
        self.undoStack.clear()

    def saveTodoDataAs(self):
        '''Save data in model as...x'''
        dir = os.path.dirname(self.filename) if self.filename is not None else "."
        self.filename, flt = QtGui.QFileDialog.getSaveFileName(None,
                "EarlyBird: Load data file", dir, "EarlyBird data (*.eb)")           
        if self.filename:
            print "Saving: ", self.filename #for debugging
            dictModel = self.modelToDict()
            with open(self.filename, 'w') as fileToWrite:
                json.dump(dictModel, fileToWrite, indent=2)
        self.undoStack.clear()

    def modelToDict(self):  #def modelToDict(self, parentItem = self.rootItem):
        '''Takes model presently in view, and saves all data as dictionary.
        Called by self.saveTodoData() and self.saveTodoDataAs()'''
        dictModel = {}       
        if self.rootItem.rowCount():           
            dictModel["taskblocks"]= self.createTaskblockList(self.rootItem)
            return dictModel

    def createTaskblockList(self, parentItem):
        '''Creates list of task blocks, and their tasks (latter using createTasklist).
        Called by modelToDict which is used to save the model as a dictionary'''
        numChildren = parentItem.rowCount()
        if numChildren:
            taskblockList = [None] * numChildren
            childList = self.getChildren(parentItem)
            for childNum in range(numChildren):
                childItem = childList[childNum]
                childTaskblockData = {}
                childTaskblockData["blockname"]=childItem.text()               
                #now see if the block has children (tasks)
                if childItem.rowCount():
                    childTaskblockData["tasks"] = self.createTaskList(childItem)
                taskblockList[childNum] = childTaskblockData
            return taskblockList
        else:
            return None

    def createTaskList(self, parentItem):
        '''Recursively traverses model creating list of tasks to
        be saved as json'''
        numChildren = parentItem.rowCount()
        if numChildren:
            taskList = [None] * numChildren
            childList = self.getChildren(parentItem)
            for childNum in range(numChildren):
                childItem = childList[childNum]
                childTaskData = {}
                childTaskData["name"] = childItem.text()
                childTaskData["done"] = True if childItem.checkState() else False
                #now see if the present child has children
                if childItem.rowCount():
                    childTaskData["tasks"] = self.createTaskList(childItem)
                taskList[childNum] = childTaskData
            return taskList
        else:
            return None

    def getChildren(self, parentItem):
        '''Returns list of child items of parentItem. Used when converting
        model to dictionary for saving as json'''
        numChildren = parentItem.rowCount()
        if numChildren > 0:
            childItemList = [None] * numChildren
            for childNum in range(numChildren):
                childItemList[childNum] = parentItem.child(childNum, 0)
        else:
            childItemList = None
        return childItemList


class CommandTextEdit(QtGui.QUndoCommand):
    '''Command for undoing/redoing text edit changes, to be placed in undostack'''
    def __init__(self, earlybirdTree, item, oldText, newText, description):
        QtGui.QUndoCommand.__init__(self, description)
        self.item = item
        self.tree = earlybirdTree
        self.oldText = oldText
        self.newText = newText

    def redo(self):      
        self.item.model().itemDataChanged.disconnect(self.tree.itemDataChangedSlot) 
        self.item.setText(self.newText)
        self.item.model().itemDataChanged.connect(self.tree.itemDataChangedSlot) 

    def undo(self):
        self.item.model().itemDataChanged.disconnect(self.tree.itemDataChangedSlot) 
        self.item.setText(self.oldText)
        self.item.model().itemDataChanged.connect(self.tree.itemDataChangedSlot) 


class CommandCheckStateChange(QtGui.QUndoCommand):
    '''Command for undoing/redoing check state changes, to be placed in undostack'''
    def __init__(self, earlybirdTree, item, oldCheckState, newCheckState, description):
        QtGui.QUndoCommand.__init__(self, description)
        self.item = item
        self.tree = earlybirdTree
        self.oldCheckState = QtCore.Qt.Unchecked if oldCheckState == 0 else QtCore.Qt.Checked
        self.newCheckState = QtCore.Qt.Checked if oldCheckState == 0 else QtCore.Qt.Unchecked

    def redo(self):
        self.item.model().itemDataChanged.disconnect(self.tree.itemDataChangedSlot) 
        self.item.setCheckState(self.newCheckState)
        self.item.model().itemDataChanged.connect(self.tree.itemDataChangedSlot) 

    def undo(self):
        self.item.model().itemDataChanged.disconnect(self.tree.itemDataChangedSlot)
        self.item.setCheckState(self.oldCheckState)
        self.item.model().itemDataChanged.connect(self.tree.itemDataChangedSlot) 


def main():
    ebApp = QtGui.QApplication(sys.argv)
    firstEb = EarlybirdTree(filename = "testFile.eb")
    firstEb.show()
    undoView = QtGui.QUndoView(firstEb.undoStack)
    undoView.show()
    sys.exit(ebApp.exec_())


if __name__ == "__main__":
    main()


earlybirdMain.py

# -*- coding: utf-8 -*-
"""
earlybirdMain.py: 
    A wrapper for the EarlybirdTree class (defined in earlybirdTree.py).
    The wrapper allows for simple menu/toolbar-based user interaction
    with an earlybird to do tree. Includes undo/redo functionality.
"""
import sys
import os
from PySide import QtGui, QtCore
from earlybirdTree import EarlybirdTree

class EarlybirdMain(QtGui.QMainWindow):
    '''Main window to wrap an EarlybirdTree'''
    def __init__(self, filename = None):
        QtGui.QMainWindow.__init__(self)
        self.setAttribute(QtCore.Qt.WA_DeleteOnClose) 
        self.view = EarlybirdTree(self, filename) 
        self.model = self.view.model
        self.windowTitleSet()
        self.setCentralWidget(self.view)
        self.createStatusBar()
        self.createActions()
        self.createToolbars()
        self.createMenus()

    def createToolbars(self):
        '''Create toolbars for actions on files and items'''
        self.fileToolbar = self.addToolBar("File actions")
        self.fileToolbar.addAction(self.fileNewAction)
        self.fileToolbar.addAction(self.fileOpenAction)
        self.fileToolbar.addAction(self.fileSaveAction)
        self.fileToolbar.addAction(self.fileSaveAsAction)
        self.itemToolbar = self.addToolBar("Item actions")
        self.itemToolbar.addAction(self.undoAction)
        self.itemToolbar.addAction(self.redoAction)

    def closeEvent(self, event):
        '''If data has been changed, ask user if they want to save it'''
        if not self.view.undoStack.isClean() and self.view.saveCheck():
            self.view.fileSave()
        self.close()

    def createMenus(self):
        '''Create menu for actions on files'''
        self.fileMenu = self.menuBar().addMenu("&File")
        self.fileMenu.addAction(self.fileOpenAction)    
        self.fileMenu.addAction(self.fileNewAction)
        self.fileMenu.addAction(self.fileSaveAction)
        self.fileMenu.addAction(self.fileSaveAsAction)

    def createActions(self):
        '''Create all actions to be used in toolbars/menus: calls createAction()'''
        #File actions
        self.fileNewAction = self.createAction("&New", slot = self.newFile,
                shortcut = QtGui.QKeySequence.New, tip = "New file",
                status = "Create a new file")
        self.fileOpenAction = self.createAction("&Open...", slot = self.fileOpen,
                shortcut = QtGui.QKeySequence.Open, tip = "Open file",
                status = "Open an existing earlybird tree")
        self.fileSaveAction = self.createAction("&Save", slot = self.fileSave,
                shortcut = QtGui.QKeySequence.Save, tip = "Save file",
                status = "Save file")
        self.fileSaveAsAction = self.createAction("Save &As", slot = self.fileSaveAs,
                shortcut = QtGui.QKeySequence.SaveAs, tip = "Save file as", status = "Save file as")
        #Item actions
        self.undoAction = self.createAction("Undo", slot = self.view.undoStack.undo,
               shortcut = QtGui.QKeySequence.Undo, tip = "Undo",
               status = "Undo changes")    
        self.redoAction = self.createAction("Redo", slot = self.view.undoStack.redo,
               shortcut = QtGui.QKeySequence.Redo, tip = "Redo",
               status = "Redo changes")

    def createAction(self, text, slot=None, shortcut=None, 
                     tip=None, status = None):
        '''Function called to create each individual action'''
        action = QtGui.QAction(text, self)
        if shortcut is not None:
            action.setShortcut(shortcut)
        if tip is not None:
            action.setToolTip(tip)
        if status is not None:
            action.setStatusTip(status)
        if slot is not None:
            action.triggered.connect(slot)
        return action 

    def createStatusBar(self):                          
        self.status = self.statusBar()
        self.status.setSizeGripEnabled(False)
        self.status.showMessage("Ready")        

    def fileSaveAs(self):
        self.view.saveTodoDataAs()
        self.windowTitleSet()

    def fileSave(self):
        if self.view.filename:        
            self.view.saveTodoData()
        else:
            self.view.saveTodoDataAs()
            self.windowTitleSet()

    def fileOpen(self):
        '''Load earlybird file from memory.'''
        if self.view.loadEarlybirdFile():
            self.model = self.view.model  
            self.windowTitleSet()
            if self.view.filename:
                filenameNopath = QtCore.QFileInfo(self.view.filename).fileName()
                self.status.showMessage("Opened file: {0}".format(filenameNopath))

    def newFile(self):
        '''Opens new blank earlybird file'''
        self.view.newFile()
        self.windowTitleSet()

    def windowTitleSet(self):
        '''Displays filename as window title, if it exists.'''
        if self.view.filename:
            self.setWindowTitle("Earlybird - {}[*]".format(os.path.basename(self.view.filename)))
        else:
            self.setWindowTitle("Earlybird - <untitled>")


def main():   
    ebApp = QtGui.QApplication(sys.argv)
    mainEb = EarlybirdMain(filename = None)#"simpleTodo.eb"
    mainEb.show()
    undoView = QtGui.QUndoView(mainEb.view.undoStack)
    undoView.show()
    sys.exit(ebApp.exec_())


if __name__ == "__main__":
    main()


testFile.eb

{
  "taskblocks": [
    {
      "tasks": [
        {
          "done": false, 
          "name": "Rake leaves"
        }, 
        {
          "done": true, 
          "name": "Eat dinner"
        }
      ], 
      "blockname": "Home"
    }, 
    {
      "tasks": [
        {
          "done": false, 
          "name": "Analysis"
        }, 
        {
          "tasks": [
            {
              "done": false, 
              "name": "Start Github project"
            }, 
            {
              "done": true, 
              "name": "Write readme.md"
            }, 
            {
              "done": false, 
              "name": "Implement functions"
            }
          ], 
          "done": false, 
          "name": "Graphing project"
        }
      ], 
      "blockname": "Work"
    }
  ]
}


最困难的部分找出/撤消/重做功能的应用程序。在最终确定上述策略之前,我已经在SO提出了三个问题。

评论

您被迫使用Python 2.7还是可以使用更新的版本?

我对Python 3过敏,但理论上可以使用它。 :)我进行了大量的科学计算,因此一直在等待Python 2直到一切都切换了。

#1 楼

从您的回答到我的评论,我将删除预期的Python 3段落(即使您在最新版本中可能喜欢一些东西,例如取代旧pathlib模块的新os.path),并直接讲到重点。 :

Qt Designer是解决之道

编写用户界面代码是没人喜欢的事情。通过在Qt Designer中设计主UI并使用pyuic4pyside-uic生成无聊的代码,您可以认真节省大量代码行。因此,您可以专注于代码的“模型和控制”部分。

国际化

尽管它可能并不重要,但即使您现在不设置任何翻译,它也不会花费多一些self.tr()来告诉您可以翻译哪些字符串。但是,如果有一天您想翻译所有内容,您将很高兴不必重新阅读整个项目来知道需要翻译哪些字符串。也就是说,Qt Designer生成的代码中会自动标记许多要翻译的字符串。

关键是一致性

阅读以下代码时:

def __init__(self, parent=None, filename = None):
    QtGui.QTreeView.__init__(self, parent=None)


有两件事引起我注意:


您打算将parent转发给QTreeView.__init__吗?如果是这样,我担心这不是在这里做的事情。

以不一致的样式方式声明参数的默认值的方式。我敢打赌,这是一个疏忽,因为您的大多数代码在样式方面看起来都是一致的。如果遵循PEP8(我想应该这样做),则应在=周围保留默认参数:

def __init__(self, parent=None, filename=None):
    QtGui.QTreeView.__init__(self, parent)




评论


\ $ \ begingroup \ $
感谢您的分析。我在初始化时使用父级的目标是设置有问题的小部件的父级,将所有权传递给父级,这样(例如)当我关闭父级时,子级会自动关闭。 Wrt QtDesigner,尽管我有很多朋友以我为我取笑,但是我很喜欢手写代码。我敢肯定,这可以节省我的时间:以前我曾将它用于简单的事情,但从未爱上它。最后,我绝对应该复审PEP8,我可能经常违反它。
\ $ \ endgroup \ $
–神经网络
15年4月29日在19:27



\ $ \ begingroup \ $
@neuronet嗯,那太糟糕了,我可以肯定父问题是错字.___。我可以告诉您,您无论如何都不会违反PEP8 :)
\ $ \ endgroup \ $
–莫文
15年4月30日在7:27

\ $ \ begingroup \ $
现在您再次提到它,我认为我不需要第二个parent = None,因为它已经作为默认值传递给__init__。
\ $ \ endgroup \ $
–神经网络
15年4月30日在13:35

#2 楼


   self.fileToolbar = self.addToolBar("File actions")
   self.fileToolbar.addAction(self.fileNewAction)
   self.fileToolbar.addAction(self.fileOpenAction)
   self.fileToolbar.addAction(self.fileSaveAction)
   self.fileToolbar.addAction(self.fileSaveAsAction)
   self.itemToolbar = self.addToolBar("Item actions")
   self.itemToolbar.addAction(self.undoAction)
   self.itemToolbar.addAction(self.redoAction)




    self.fileMenu.addAction(self.fileOpenAction)    
    self.fileMenu.addAction(self.fileNewAction)
    self.fileMenu.addAction(self.fileSaveAction)
    self.fileMenu.addAction(self.fileSaveAsAction)



你有一堆重复的代码。最好改成适当的数据结构,然后隐式地遍历它们。

评论


\ $ \ begingroup \ $
不幸的是,通常来说,对于UI代码来说,它不能很好地工作。通常情况下,事情已经足够令人讨厌了,但是当您尝试制作一些通用代码来处理它们时,事情又相去甚远,有太多特殊情况:/
\ $ \ endgroup \ $
–莫文
15年4月29日在14:03

\ $ \ begingroup \ $
是的,我认为您需要4次以上的重复操作,然后才能在操作中使用for操作保存任何行:menu.addAction(action)一种解决方案。如果您定期进行小批量操作,则可以创建一个辅助函数,但是我常常最终倾向于更靠近库并且不要过度抽象UI代码。
\ $ \ endgroup \ $
–flutefreak7
18-09-21在4:38