PyQt 예제 3.3.3

이번 글에서는 파일의 저장, 불러오기 및 프로그램의 상태 저장을 다루어볼 것이다. 내용이 많기 때문에 상당한 코드가 추가 및 변경되었다.

먼저, 현재 프로그램에서는 파일을 저장하거나 불러올 부분이 마땅히 없다. (이미지 저장 및 불러오기 부분이 가능할 수도 있지만 수정하는 부분이 많지 않기 때문에 제외했다.)

그래서 텍스트 에디터를 추가하여 텍스트 파일을 저장하고 불러오는 기능을 추가했다.

설명에 들어가기 전에, 이번 버전에서는 여러 코드의 추가 및 수정 후에 릴리즈를 했기 때문에 순서 없이 코드 변경이 가해졌다. 그래서 UI에서 변경된 부분을 설명한 후에 메소드 부분에서 변경된 부분을 설명하겠다.



먼저, 텍스트 에디터를 Dock에 추가하고 기존 리스트 Dock은 일반 위젯 위치로 변경을 시켰다.
        ### Item Widgets ###
        itemLayout = QFormLayout()
        itemGroup = QGroupBox("아이템 위젯")
        itemGroup.setLayout(itemLayout)

        # List Widget
        self.listWidget = QListWidget()
        self.listWidget.addItems(["리스트 항목 {}".format(k) 
                                    for k in range(1, 5)])
        self.listWidget.setMinimumSize(100, 100)

        self.AddRows(itemLayout, (
                ("리스트 위젯: ", self.listWidget),))

######## 중략

        # Plain Text Edit
        self.plainTextEdit = QPlainTextEdit()
        self.plainTextEdit.setMinimumSize(200, 200)
        self.plainTextEditChanged = False
        self.plainTextEditChangedByUser = True
        self.plainTextEditFilePath = None

        def UserChangePlainTextEdit():
            if (not self.plainTextEditChanged) and \
                                            self.plainTextEditChangedByUser:
                self.plainTextEditChanged = True
                self.UpdatePlainTextEdit(None)

        self.connect(self.plainTextEdit, SIGNAL("textChanged()"), 
                                UserChangePlainTextEdit)
        
        self.plainTextEditDock = QDockWidget("텍스트 에디트 Dock", self)
        self.plainTextEditDock.setObjectName("PlainTextEditDockWidget")
        self.plainTextEditDock.setWidget(self.plainTextEdit)
        self.addDockWidget(Qt.RightDockWidgetArea, self.plainTextEditDock)

        # Plain Test Edit Context Menu
        self.plainTextEditDock.setContextMenuPolicy(Qt.ActionsContextMenu)
        self.AddActions(self.plainTextEditDock, (newTextFileAction,
            openTextFileAction, saveTextFileAction, saveAsTextFileAction))

위 코드가 추가되었고, 기존의 리스트 Dock 부분을 제거되었다. 그리고 텍스트 에디터 Dock을 추가하였다.

여기서 plainTextEditChanged 와 plainTextEditChangedByUser 변수는 텍스트의 변경을 확인하기 위해서 추가된 것이다. 이후에 "새 텍스트 파일"이나 텍스트 파일 수정이 이루어지게 되면, 텍스트 변경 여부를 확인해서 Dock의 제목을 변경하게 되는데, 이때 사용자에 의해서 변경된 것인지, 아니면 프로그램에 의해서 변경된 것인지 판단하기 위해서 후자의 변수도 추가하게 되었다.
(QLineEdit 의 경우에는 프로그램에 의한 변경 여부를 내장된 기능을 통해서 쉽게 알 수 있다. 그러나 PlainTextEdit 의 경우에는 그러한 기능이 없어서 따로 변수를 추가하여 직접 확인하는 것이다.)

그 다음으로 UserChangePlainTextEdit은 사용자에 의한 텍스트 에디터 변경을 확인하는 함수인데, 에디터가 사용자에 의해 변경되었을 경우, 텍스트 에디터가 변경되었음을 알리고, Dock의 제목을 갱신하도록 하였다.

그리고 텍스트 에디터가 변경된 경우 textChanged 시그널이 발생하므로 이를 위에서 설명한 함수와 Connect 하도록 하였다.

그 아래 부분은 텍스트 에디터를 Dock에 넣는 부분과 텍스트 에디터 Dock의 컨텍스트 메뉴를 직접 지정한 액션 메뉴로 설정하기 위해서 Qt.ActionsContextMenu를 통해서 컨텍스트 메뉴 설정을 변경하였고, 액션을 추가하였다.

일단, 위에서 추가되지 않은 함수들을 pass로 추가하고 프로그램을 실행시켜보면 텍스트 에디터가 추가되었음을 확인할 수 있다.



그 다음으로 "파일" 메뉴를 추가해보겠다. 파일 메뉴를 통해서 텍스트 파일의 열기, 저장, 종료 등을 사용할 수 있도록 할 것이다.
#### 변경된 부분
    def AddActions(self, target, actions):
        for action in actions:
            if isinstance(action, QAction):
                target.addAction(action)
            elif isinstance(action, QMenu):
                target.addMenu(action)
            else:
                target.addSeparator()
먼저, AddActions 이 위처럼 변경되었다.
        # New Text File Action
        newTextFileAction = self.CreateAction("새 텍스트 파일(&N)", 
                ":newTextFileIcon.png", QKeySequence.New, 
                "새 텍스트 파일을 엽니다.", self.NewTextFile)

        # Open Text File Action
        openTextFileAction = self.CreateAction("텍스트 파일 열기(&O)", 
                ":openTextFileIcon.png", QKeySequence.Open, 
                "텍스트 파일을 엽니다.", self.OpenTextFile)

        # Save Text File Action
        saveTextFileAction = self.CreateAction("텍스트 파일 저장(&S)", 
                ":saveTextFileIcon.png", QKeySequence.Save, 
                "텍스트 파일을 저장합니다.", self.SaveTextFile)

        # Save As Text File Action
        saveAsTextFileAction = self.CreateAction(
                "다른 이름으로 텍스트 파일 저장(&A)", 
                ":saveAsTextFileIcon.png", QKeySequence.SaveAs, 
                "다른 이름으로 텍스트 파일을 저장합니다.", self.SaveAsTextFile)

        # Program Quit Action
        quitAction = self.CreateAction("끝내기(&Q)", ":quitApplication.png",
                QKeySequence.Quit, "프로그램을 종료합니다.", self.close)

######## 중략

        # 최근 파일 목록
        self.recentFilesMenu = QMenu("최근에 연 파일")
        self.connect(self.recentFilesMenu, SIGNAL("aboutToShow()"),
                        self.UpdateRecentFilesMenu)

        # 파일
        fileMenu = QMenu()
        self.AddActions(fileMenu, (newTextFileAction, None, openTextFileAction,
            self.recentFilesMenu, None, saveTextFileAction, saveAsTextFileAction,
            None, quitAction))
        fileMenuAction = self.CreateAction("파일(&F)", None, None, 
                "파일의 열기 및 저장 등을 포함합니다")
        fileMenuAction.setMenu(fileMenu)
        self.menuBar().addAction(fileMenuAction)
그리고 액션 및 메뉴가 위처럼 추가되었는데, QKeySequence를 통해서 단축키를 사용할 수 있도록 하였다. 따로 Ctrl+N 와 같은 단축키를 직접 지정하지 않은 이유는, 윈도우즈나 리눅스, 맥마다 쓰는 기본 단축키들이 다르기 때문이다.

위와 같이 단축키 상수들을 사용하면 사용자의 환경마다 기본으로 지정된 단축키들이 할당된다.

그리고 메뉴에서 "최근에 연 파일"의 경우 시그널을 aboutToShow를 사용하여 메뉴가 보여지기 전에 슬롯을 호출하도록 하였다. 최근에 연 파일의 경우에는 액션들이 미리 지정된 것이 아니라, 런타임에 동적으로 추가되고 삭제되기 때문에 메뉴가 열릴 때마다 슬롯을 호출해야 한다.

이렇게 하고 나면 추가되지 않은 함수들에 대해서 pass를 지정하고, 프로그램을 실행하면 파일 메뉴가 추가된 것을 알 수 있다.



그 다음으로 텍스트 에디터의 변경 시에 상태 표시줄에도 설명을 띄우기 위해서 상태 표시줄을 클래스의 속성으로 변경을 시켰다.
        self.statusBar = self.statusBar()
        self.statusBar.addPermanentWidget(statusBarLabel)
        self.statusBar.showMessage("실행 완료", 5000)

그리고 프로그램의 상태를 저장하고 복원할 수 있는 기능을 위해서 아래 코드도 추가하였다.
        ### Settings Restore ###
        settings = QSettings()
        self.recentFiles = settings.value("RecentFiles") or []
        self.restoreGeometry(settings.value("mainWindow.Geometry",
                QByteArray()))
        self.restoreState(settings.value("mainWindow.State", QByteArray()))

######## 중략

if __name__ == "__main__":
    app = QApplication(sys.argv)
    app.setOrganizationName("bluekyu")
    app.setOrganizationDomain("bluekyu.me")
    app.setApplicationName(__program_name__)
    app.setWindowIcon(QIcon(":mainIcon.png"))
    mainWindow = MainWindow()
    mainWindow.show()
    app.exec_()
먼저, if 문 아래 코드를 보면, 현재 프로그램에 조직의 이름과 도메인, 프로그램 명을 설정해주었다. 이렇게 지정함으로써 다른 프로그램들과 구분을 지을 수 있다.

만약, 프로그램 명이나 도메인 명이 중복되면 다른 프로그램들과 오류가 발생할 수 있기 때문에, 되도록 유일한 값이 되어야 한다.

이들의 값들은 윈도우즈의 경우 레지스터리에 저장되고, 리눅스의 경우에는 홈 폴더 내에 .config 폴더에 추가되고, 맥은 홈폴더에 Library/Preferences에 추가된다.

다시 위 코드를 보면, QSettings 를 통해 설정을 가져온 다음, 최근 파일과 창의 위치 및 크기를 복원하기 위해서 설정에서 지정한 값들을 가져온다. 이때, QSettings는 원래 QVariant 객체를 넘기도록 되어 있지만 파이썬3 부터는 None을 제외한 모든 파이썬 객체와 동등하도록 변경되었다.

따라서 파이썬2 이하에서는 QVariant 객체를 알맞은 다른 객체로 변경을 해야 했지만, 파이썬3 부터는 바로 리턴된 객체를 사용할 수 있다.

다만, 창의 위치나 크기의 경우에는 QByteArray 객체가 필요하므로 value로 값을 가져올 때 리턴되는 객체를 QByteArray로 하도록 지정하였다.



이로써 UI 부분에 대한 변경은 완료 되었다. 이제 메소드 부분을 보겠다.

먼저, 텍스트 에디터 Dock의 갱신 부분이다.
#
    def UpdatePlainTextEdit(self, message):
        if message:
            self.statusBar.showMessage(message, 5000)

        fileName = basename(self.plainTextEditFilePath) if \
                        self.plainTextEditFilePath else "이름 없음"
        self.plainTextEditDock.setWindowTitle(
                                "{}[*]".format(fileName))
        self.plainTextEditDock.setWindowModified(self.plainTextEditChanged)
메시지가 있을 경우에 상태 표시줄에 메시지를 띄우도록 하였다. 그리고 Dock 에는 파일 명이 나타나도록 하였는데, 현재 열린 파일이 없다면, "이름 없음"이 나타나도록 하였다.

여기서 self.plainTextEditFilePath은 현재 열린 텍스트 파일의 전체 경로를 가지고 있도록 하였다. 따라서 파일 명만 가져오기 위해서 basename 함수를 사용하였다. 이는 from os.path import basename 를 통해서 가져온 함수이다.

그리고 Dock 에 파일 명을 나타낼 때 [*]을 사용하였는데, 이는 현재 창의 수정 여부를 알려줄 수 있도록 하는 값이다. 일반적으로 텍스트 에디터를 수정하면 제목에 *이 나타나거나 맥의 경우에는 닫기 단추가 변한다.

이를 지정하기 위해서 추가된 값인데, 수정되지 않은 경우에는 *가 표시되지 않다가 수정된 후에는 *가 표시된다.

그리고 수정 여부를 알려주기 위해서 setWindowModified 메소드를 통해서 그 여부를 알려줄 수 있다.


그 다음으로 텍스트 파일의 열기, 저장에 관한 코드이다.
#
    def TextFileSaveOk(self):
        if self.plainTextEditChanged:
            answer = QMessageBox.question(self, "텍스트 파일 저장 확인",
                    "텍스트 파일이 저장되지 않았습니다. 저장하시겠습니까?",
                    QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel)
            if answer == QMessageBox.Cancel:
                return False
            elif answer == QMessageBox.Yes:
                self.SaveTextFile()
        return True

    def NewTextFile(self):
        if not self.TextFileSaveOk():
            return
        self.plainTextEditFilePath = None
        self.plainTextEditChanged = False
        self.plainTextEditChangedByUser = False
        self.plainTextEdit.clear()
        self.plainTextEditChangedByUser = True

        self.UpdatePlainTextEdit("새 파일이 열림")

    def OpenTextFile(self):
        if not self.TextFileSaveOk():
            return
        fileDir = dirname(self.plainTextEditFilePath) if \
                                    self.plainTextEditFilePath else "."

        filePath = QFileDialog.getOpenFileName(self, "텍스트 파일 열기",
                        fileDir, "텍스트 파일(*.txt);;모든 파일(*.*)")

        if filePath:
            self.LoadTextFile(filePath)

    def LoadTextFile(self, filePath):
        action = self.sender()
        if isinstance(action, QAction) and not self.TextFileSaveOk():
            return
        if filePath:
            self.plainTextEditFilePath = filePath
            self.plainTextEditChanged = False
            self.plainTextEditChangedByUser = False
            text = open(filePath, "r").read()
            self.plainTextEdit.setPlainText(text)
            self.plainTextEditChangedByUser = True
            self.AddRecentFiles(filePath)
            self.UpdatePlainTextEdit("파일 열기 성공")

    def SaveTextFile(self):
        if not self.plainTextEditChanged:
            return
        if self.plainTextEditFilePath is None:
            self.SaveAsTextFile()
        else:
            textFile = open(self.plainTextEditFilePath, "w")
            textFile.write(self.plainTextEdit.toPlainText())
            self.plainTextEditChanged = False
            self.UpdatePlainTextEdit("파일 저장 완료")

    def SaveAsTextFile(self):
        fileDir = dirname(self.plainTextEditFilePath) if \
                            self.plainTextEditFilePath else "."
        filePath = QFileDialog.getSaveFileName(self, "텍스트 파일 저장",
                                fileDir, "텍스트 파일(*.txt);;모든 파일(*.*)")
        
        if filePath:
            self.AddRecentFiles(filePath)
            self.plainTextEditFilePath = filePath
            self.plainTextEditChanged = True
            self.SaveTextFile()
위에서부터 순서대로 살펴보면, TextFileSaveOk 메소드는 텍스트 파일의 저장 여부를 확인하는 메소드이다. 텍스트 파일이 수정되었다면 저장 여부를 묻고 저장을 하지 않는다면 False를 리턴, 저장을 한다면 SaveTextFile 메소드를 호출하여 저장을 하도록 하였고, 수정조차 안되었다면 True를 리턴하도록 하였다.

NewTextFile은 새 텍스트 파일을 여는 것인데, 저장 여부를 확인한 후에 텍스트 에디터에 대한 속성들을 새로 초기화 해주도록 하였다.

OpenTextFile은 텍스트 파일을 여는 것인데, 기존에 있는 텍스트 파일의 저장 여부를 확인한다. 그런 다음, 현재 텍스트 파일이 있다면 그 파일의 경로를 가져오고, 없다면 현재 실행되고 있는 경로인 "."를 가져오도록 하였다.
(이때, dirname은 폴더의 경로만 얻기 위한 것으로 from os.path import dirname 에서 가져왔다.)

그 다음, 이전에 했던 것처럼 QFileDialog를 열어서 파일 경로를 얻은 다음, LoadTextFile을 통해서 파일을 불러오도록 하였다.

여기서 Load와 Open을 따로 구분한 이유는, "최근에 연 파일"에서 파일을 누른 경우, 파일 경로가 이미 지정되어 있으므로 LoadTextFile을 통해서 바로 불러올 수 있기 때문이다. 따라서 Open을 통해서 파일을 따로 찾는 메소드와 Load를 통해서 파일을 가져오는 메소드로 분리한 것이다.

LoadTextFile은 파일을 불러오는 메소드로, 파일 경로가 주어지면, 텍스트 에디터의 속성들을 초기화 해주고 open 함수를 통해서 텍스트 파일을 읽어 오도록 하였다. 이때, isinstance로 액션의 인스터스 여부를 확인하는데, 이는 "최근에 연 파일"을 확인하기 위함이다.

만약, "최근에 연 파일"의 액션으로부터 메소드가 호출 되었다면, 저장 여부를 확인해야 하고, 단순한 메소드 호출이면 바로 텍스트 파일을 불러오기 위함이다.

SaveTextFile은 파일을 저장하는 메소드로, 텍스트 파일이 수정 되었을 경우에만 저장을 시도한다. 이때, 텍스트 파일의 경로가 없다면(즉, 새로 텍스트 파일을 만든 경우) SaveAsTextFile 함수를 호출하여 "다른 이름으로 텍스트 파일 저장"을 시도한다.

텍스트 파일의 경로가 주어져 있다면, 그 경로에 텍스트 파일을 저장한다.

SaveAsTextFile은 다른 이름으로 텍스트 파일을 저장하는 메소드로, 저장할 텍스트 파일의 경로를 얻기 위해서 QFileDialog의 getSaveFileName을 이용한다.

파일의 저장 경로를 얻었다면 텍스트 파일의 경로를 설정해주고, 다시 SaveTextFile을 호출해서 파일을 저장한다.



이렇게 해서 텍스트 파일 저장이 완료되었다. 그리고 텍스트 파일을 수정한 후에 바로 프로그램을 종료할 수 있기 때문에, 종료 전에 파일의 저장 여부를 물어야 할 것이다.
#
    def closeEvent(self, event):
        if self.TextFileSaveOk():
            settings = QSettings()
            settings.setValue("RecentFiles", self.recentFiles or [])
            settings.setValue("mainWindow.Geometry", self.saveGeometry())
            settings.setValue("mainWindow.State", self.saveState())
        else:
            event.ignore()
closeEvent라는 메소드를 오버로딩 하는 것이다. 이미 closeEvent라는 메소드가 존재하는데, 이 메소드는 프로그램이 종료될 때 호출된다.

이를 오버로딩 하여 프로그램이 종료될 때 이 메소드가 호출되도록 한다. 간단히 파일 저장 여부만 확인하면 되므로, 파일 저장이 되어 있다면 현재 프로그램의 설정들을 저장하고 프로그램을 종료할 것이다.

하지만 저장되어 있지 않다면 종료 이벤트(event 변수)를 무시하기 위해서 ignore를 호출한다.



이제 마지막으로 "최근 연 파일" 메뉴에 대한 메소드가 남았다.
#
    def AddRecentFiles(self, filePath):
        if filePath is None:
            return
        if filePath in self.recentFiles:
            self.recentFiles.remove(filePath)
        self.recentFiles.insert(0, filePath)
        if len(self.recentFiles) > 9:
            self.recentFiles.pop()

    def UpdateRecentFilesMenu(self):
        self.recentFilesMenu.clear()
        recentFiles = []
        for filePath in self.recentFiles:
            if filePath != self.plainTextEditFilePath and \
                    QFile.exists(filePath):
                recentFiles.append(filePath)

        for i, filePath in enumerate(recentFiles):
            action = QAction("&{} {}".format(i+1, filePath), self)
            action.setData(filePath)
            self.connect(action, SIGNAL("triggered()"), 
                            partial(self.LoadTextFile, filePath))
            self.recentFilesMenu.addAction(action)
AddRecentFiles는 주어진 파일 경로를 최근 파일 리스트에 추가하는 것이다.

먼저, 이미 리스트에 파일이 존재할 경우 이를 제거하고 새로 맨 앞에 파일을 추가한다. 그리고 리스트 길이가 9가 넘을 경우 맨 마지막 파일을 제거하도록 한다.

UpdateRecentFilesMenu는 최근 연 파일 메뉴를 갱신하는 메소드이다. 먼저, 현재 있는 메뉴에 액션들을 모두 제거하기 위해 clear를 하였다.

그리고 최근 파일 리스트에 있는 경로들에 대해서 존재 검사를 실시한다. 즉, 최근에 연 파일 리스트에 있는 파일이 존재할 경우에만 메뉴에 추가하는 것이다.

파일을 추려낸 다음, enumerate를 통해서 인덱스와 값을 같이 가져와서 순서를 매기면서 액션을 추가한다. 이때 액션에 데이터를 포함시키기 위해서 setData를 사용하였다.

그리고 액션이 실행되었을 때 파일을 불러와야 하므로 partial 함수를 통해서 LoadTextFile에 filePath를 넘기도록 하였다.

이때, 슬롯에 인자를 넘기기 위해 lambda 대신에 partial 함수를 쓰는 이유는 새로운 함수를 생성하기 위함이다. 처음에 lambda를 사용을 했었는데, 이렇게 하게 되면 lambda 의 코드가 넘겨져서, 모든 액션들이 마지막 filePath를 인자로 갖게 된다.
(즉, 모든 액션들이 똑같은 filePath를 갖는다.)

그래서 partial 함수를 사용하였더니 제대로 작동하였다. partial 함수의 경우, 함수와 인자를 함께 묶어서 완전히 새로운 함수를 리턴하게 되므로 각 함수마다 제대로 된 filePath를 갖는다.
(http://www.mail-archive.com/pyqt@riverbankcomputing.com/msg24856.html 메일링 리스트 참조)
(그리고 이 글을 쓰면서 추가된 답변을 확인했는데, 이전에 lambda 코드를 잘못 사용해서 그러한 오류가 발생한 것이었다...
lambda: self.LoadTextFile(filePath)로 사용을 했었는데, 이것이 아니라 lambda x=filePath: self.LoadTextFile(x) 로 사용을 했어야 했다...)

액션을 연결해준 다음, 메뉴에 액션을 추가하게 되면 메뉴가 완성된다.


이렇게 해서 파일의 열기 및 저장이 완성되었다. 순서가 매우 복잡해서 많이 혼동이 될 수 있다. 특히, 이 부분에 추가된 내용이 너무 많아서 설명도 복잡했다.

https://github.com/bluekyu/PyQt-Examples/commits/master

위의 주소에 보면 커밋한 코드들이 나와 있다. 3.3.2 이후에 올려진 코드들을 볼 수도 있고, 바로 이전의 코드들과의 비교점도 확인할 수 있으므로 이를 통해서 코드의 변경 과정을 알 수 있다.

이 순서대로 따라가는 것이 가장 좋을 것이다.

댓글 3개:

  1. 글 잘 읽었습니다..
    위젯들은 왼쪽 오른쪽 위 아래만 배치 되던데 .. 가운데 배치하는 방법은 없나요??
    아니면 위젯과 일반 listview를 같이 배치하는 방법이 있을까요??

    답글삭제
  2. 글 잘 읽었습니다..
    위젯들은 왼쪽 오른쪽 위 아래만 배치 되던데 .. 가운데 배치하는 방법은 없나요??
    아니면 위젯과 일반 listview를 같이 배치하는 방법이 있을까요??

    답글삭제
  3. 글 잘 읽었습니다..
    위젯들은 왼쪽 오른쪽 위 아래만 배치 되던데 .. 가운데 배치하는 방법은 없나요??
    아니면 위젯과 일반 listview를 같이 배치하는 방법이 있을까요??

    답글삭제

크리에이티브 커먼즈 라이선스