PyQt5でメモ帳を作ってみる

2021/04/18 categories:TOOL| tags:TOOL|PyQt5|Python|

PyQt5のコーディング練習としてメモ帳を作ってみました。

実装する機能

実装する機能は以下の4つです。

QMainWindowを表示

まずは以下のコードのように、QTextEditをセットしたQMainWindowクラスを作成して、ウィンドウを表示します。QTextEditにはコンテキストメニューやアンドゥ、リドゥなどの機能が標準で実装されているようなので、これらの機能を自分で実装する必要はないみたいです。

import sys
from PyQt5 import QtWidgets, QtCore

class MemoPad(QtWidgets.QMainWindow):
    def __init__(self):
        super(MemoPad, self).__init__()
        self.resize(400, 400)
        self.setWindowTitle('Memo pad')
        self.textEdit = QtWidgets.QTextEdit(self)
        self.setCentralWidget(self.textEdit)

if __name__ == '__main__':
    app = QtWidgets.QApplication(sys.argv)
    memoPad = MemoPad()
    memoPad.show()
    app.exec()

メニューバーの追加

メニューバーにファイル、編集などのメニューを追加します。メモ帳としての基本的な機能であるファイルを開いたり、保存する機能や、検索や置換などを実装します。

import sys
from PyQt5 import QtWidgets, QtCore

class MemoPad(QtWidgets.QMainWindow):
    def __init__(self):
        super(MemoPad, self).__init__()
        self.resize(400, 400)
        self.setWindowTitle('Memo pad')
        self.textEdit = QtWidgets.QTextEdit(self)
        self.setCentralWidget(self.textEdit)

if __name__ == '__main__':
    app = QtWidgets.QApplication(sys.argv)
    memoPad = MemoPad()
    memoPad.show()
    app.exec()

ファイルの保存と開く機能の実装

QActionのtriggeredシグナルにファイルを保存するメソッドと開くメソッドをconnectして、SaveアクションやOpenアクションをクリックしたときにファイルを保存したり開いたり出来るようにします。

開くファイルのパスの取得には、QFileDialog.getOpenFileNameを使用します。

class MemoPad(QtWidgets.QMainWindow):
    def __init__(self):
        self.saveAction.triggered.connect(self.saveFile)

    def saveFile(self):
        fileName, _ = QtWidgets.QFileDialog.getSaveFileName(None, 'Save file', '', 'Text file(*.txt)')
        if fileName == '':
            return
        with open(fileName, mode='w') as f:
            f.write( self.textEdit.toPlainText() )

保存先のパスを取得するには、QFileDialog.getSaveFileNameを使用します。

class MemoPad(QtWidgets.QMainWindow):
    def __init__(self):
        self.openAction.triggered.connect(self.openFile)

    def openFile(self):
        fileName, _ = QtWidgets.QFileDialog.getOpenFileName(None, 'Open file', '', 'Text file(*.txt)')
        if fileName == '':
            return
        with open(fileName, mode='r') as f:
            self.textEdit.setPlainText( f.read() )

テキストの検索

テキスト検索の機能を実装するために、Dialogクラスを継承したFindDialogクラスを作成して検索用のウィンドウを作成します。Dialogを継承している理由は、このクラスを置換用のウィンドウとしても使用する為です。Dialogクラスには、検索と置換に必要なUIをすべて実装して、FindDialogクラスで検索に必要ない機能のUIを非表示にしています。

class Dialog(QtWidgets.QDialog):
    def __init__(self, parent=None):
        super(Dialog, self).__init__(parent)
        self.label_0 = QtWidgets.QLabel('Find what')
        self.label_1 = QtWidgets.QLabel('Replace with')
        self.lineEdit_0 = QtWidgets.QLineEdit()
        self.lineEdit_1 = QtWidgets.QLineEdit()
        self.button_0 = QtWidgets.QPushButton('Find next')
        self.button_1 = QtWidgets.QPushButton('Find beafore')
        self.button_2 = QtWidgets.QPushButton('Replace')
        self.button_3 = QtWidgets.QPushButton('Replace all')
        self.button_4 = QtWidgets.QPushButton('Cancel')

        self.setLayout( QtWidgets.QGridLayout() )
        self.layout().addWidget( self.label_0,    0, 0, 1, 1 )
        self.layout().addWidget( self.label_1,    1, 0, 1, 1 )
        self.layout().addWidget( self.lineEdit_0, 0, 1, 1, 1 )
        self.layout().addWidget( self.lineEdit_1, 1, 1, 1, 1 )
        self.layout().addWidget( self.button_0,   0, 2, 1, 1 )
        self.layout().addWidget( self.button_1,   1, 2, 1, 1 )
        self.layout().addWidget( self.button_2,   2, 2, 1, 1 )
        self.layout().addWidget( self.button_3,   3, 2, 1, 1 )
        self.layout().addWidget( self.button_4,   4, 2, 1, 1 )
        self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint)
        
        self.button_0.clicked.connect(lambda : self.parent().findText(self.lineEdit_0.text()))
        self.button_1.clicked.connect(lambda : self.parent().findText(self.lineEdit_0.text(), True))
        self.button_2.clicked.connect(lambda : self.parent().replace( self.lineEdit_0.text(), self.lineEdit_1.text() ))
        self.button_3.clicked.connect(lambda : self.parent().textEdit.replaceAll( self.lineEdit_0.text(), self.lineEdit_1.text() ))
        self.button_4.clicked.connect(lambda : self.reject())
        
class FindDialog(Dialog):
    def __init__(self, parent=None):
        super(FindDialog, self).__init__(parent)
        self.setWindowTitle('Find')
        self.label_1.hide()
        self.lineEdit_1.hide()
        self.button_2.hide()
        self.button_3.hide()

検索の処理は、MemoPadクラスのfindTextで実行しています。textEdit.toPlainText()で取得したテキストデータに対して処理を行い、正順に検索を行う場合はstr.findを使用し、逆順に検索を行う場合はstr.rfindを使用します。検索結果をMemoPadクラスのself.findIndexに格納しておくことで、前回の検索結果の位置よりも後のテキストデータから検索できるようにしています。また、textEdit.textCursor()でテキストエディットのカーソルを取得して検索結果の位置をsetPositionでセットすることで、検索結果をUIに反映させています。

class MemoPad(QtWidgets.QMainWindow):
    def __init__(self):
        self.findIndex = -1
        self.replaceAction.triggered.connect(lambda : ReplaceDialog(self).show())
        
    def findText(self, findText, reverse=False):
        if findText == '':
            return
        
        text = self.textEdit.toPlainText()
        if reverse:
            self.findIndex = text.rfind( findText, 0, self.findIndex )
        else:
            self.findIndex = text.find( findText, self.findIndex + 1 )
        
        if self.findIndex == -1:
            return
        
        textCursor = self.textEdit.textCursor()
        textCursor.setPosition(self.findIndex)
        textCursor.setPosition(self.findIndex + len(findText), QtGui.QTextCursor.KeepAnchor)
        self.textEdit.setTextCursor(textCursor)
        self.activateWindow()

テキストの置換

置換のUIも検索と同様にDialogクラスを継承して、置換に不要なUIを非表示にするという実装をしています。

class ReplaceDialog(Dialog):
    def __init__(self, parent=None):
        super(ReplaceDialog, self).__init__(parent)
        self.setWindowTitle('Replace')
        self.button_1.hide()

テキストの置換は、選択されているテキストが検索する文字列と一致した場合、その文字列を置換するという処理にしています。その処理の後にテキストを検索する処理を行っているので、その検索処理で次に置換するテキストを選択することになります。

class MemoPad(QtWidgets.QMainWindow):
    def replace(self, findText, replaceText):
        text = self.textEdit.toPlainText()
        if findText == self.textEdit.textCursor().selectedText():
            index = self.textEdit.textCursor().selectionStart()
            replaced = text[ : index ] + replaceText + text[index + len(findText) : ]
            self.textEdit.setPlainText(replaced)
        self.findText(findText)

    def replaceAll(self, findText, replaceText):
        self.textEdit.setPlainText(
            self.textEdit.toPlainText().replace(findText, replaceText)
        )

フォントの設定

フォントの設定にはQFontDialogが用意されているので実装は簡単です。QFontDialogを表示して、OKがクリックされたら指定されたフォントをテキストエディットにセットするという処理だけで実装できます。

class MemoPad(QtWidgets.QMainWindow):
    def __init__(self):
        self.fontAction.triggered.connect(self.changeFont)

    def changeFont(self):
        font, ok = QtWidgets.QFontDialog.getFont(self.textEdit.currentFont(), self)
        if not ok:
            return
        self.textEdit.setFont(font)

所感

たった133行でこれだけのGUIアプリが作れてしまうのでPyQt5は便利だと思います。ファイルやフォントのダイアログのようにダイアログが標準で用意されていて良いですね。

ソースコード

import sys
from PyQt5 import QtWidgets, QtCore, QtGui

class MemoPad(QtWidgets.QMainWindow):
    def __init__(self):
        super(MemoPad, self).__init__()
        self.findIndex = -1

        self.resize(400, 400)
        self.setWindowTitle('Memo pad')
        self.textEdit = QtWidgets.QTextEdit(self)
        self.setCentralWidget(self.textEdit)

        self.fileMenu = self.menuBar().addMenu('File')
        self.openAction = self.fileMenu.addAction('Open')
        self.saveAction = self.fileMenu.addAction('Save')
        
        self.editMenu = self.menuBar().addMenu('Edit')
        self.findAction = self.editMenu.addAction('Find')
        self.replaceAction = self.editMenu.addAction('Replace')
        
        self.formatMenu = self.menuBar().addMenu('Format')
        self.fontAction = self.formatMenu.addAction('Font')

        self.openAction.triggered.connect(self.openFile)
        self.saveAction.triggered.connect(self.saveFile)
        self.findAction.triggered.connect(lambda : FindDialog(self).show())
        self.replaceAction.triggered.connect(lambda : ReplaceDialog(self).show())
        self.fontAction.triggered.connect(self.changeFont)

    def openFile(self):
        fileName, _ = QtWidgets.QFileDialog.getOpenFileName(None, 'Open file', '', 'Text file(*.txt)')
        if fileName == '':
            return
        with open(fileName, mode='r') as f:
            self.textEdit.setPlainText( f.read() )

    def saveFile(self):
        fileName, _ = QtWidgets.QFileDialog.getSaveFileName(None, 'Save file', '', 'Text file(*.txt)')
        if fileName == '':
            return
        with open(fileName, mode='w') as f:
            f.write( self.textEdit.toPlainText() )

    def findText(self, findText, reverse=False):
        if findText == '':
            return
        
        text = self.textEdit.toPlainText()
        if reverse:
            self.findIndex = text.rfind( findText, 0, self.findIndex )
        else:
            self.findIndex = text.find( findText, self.findIndex + 1 )
        
        if self.findIndex == -1:
            return
        
        textCursor = self.textEdit.textCursor()
        textCursor.setPosition(self.findIndex)
        textCursor.setPosition(self.findIndex + len(findText), QtGui.QTextCursor.KeepAnchor)
        self.textEdit.setTextCursor(textCursor)
        self.activateWindow()
        
    def replace(self, findText, replaceText):
        text = self.textEdit.toPlainText()
        if findText == self.textEdit.textCursor().selectedText():
            index = self.textEdit.textCursor().selectionStart()
            replaced = text[ : index ] + replaceText + text[index + len(findText) : ]
            self.textEdit.setPlainText(replaced)
        self.findText(findText)

    def replaceAll(self, findText, replaceText):
        self.textEdit.setPlainText(
            self.textEdit.toPlainText().replace(findText, replaceText)
        )

    def changeFont(self):
        font, ok = QtWidgets.QFontDialog.getFont(self.textEdit.currentFont(), self)
        if not ok:
            return
        self.textEdit.setFont(font)

class Dialog(QtWidgets.QDialog):
    def __init__(self, parent=None):
        super(Dialog, self).__init__(parent)
        self.label_0 = QtWidgets.QLabel('Find what')
        self.label_1 = QtWidgets.QLabel('Replace with')
        self.lineEdit_0 = QtWidgets.QLineEdit()
        self.lineEdit_1 = QtWidgets.QLineEdit()
        self.button_0 = QtWidgets.QPushButton('Find next')
        self.button_1 = QtWidgets.QPushButton('Find beafore')
        self.button_2 = QtWidgets.QPushButton('Replace')
        self.button_3 = QtWidgets.QPushButton('Replace all')
        self.button_4 = QtWidgets.QPushButton('Cancel')

        self.setLayout( QtWidgets.QGridLayout() )
        self.layout().addWidget( self.label_0,    0, 0, 1, 1 )
        self.layout().addWidget( self.label_1,    1, 0, 1, 1 )
        self.layout().addWidget( self.lineEdit_0, 0, 1, 1, 1 )
        self.layout().addWidget( self.lineEdit_1, 1, 1, 1, 1 )
        self.layout().addWidget( self.button_0,   0, 2, 1, 1 )
        self.layout().addWidget( self.button_1,   1, 2, 1, 1 )
        self.layout().addWidget( self.button_2,   2, 2, 1, 1 )
        self.layout().addWidget( self.button_3,   3, 2, 1, 1 )
        self.layout().addWidget( self.button_4,   4, 2, 1, 1 )
        self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint)
        
        self.button_0.clicked.connect(lambda : self.parent().findText(self.lineEdit_0.text()))
        self.button_1.clicked.connect(lambda : self.parent().findText(self.lineEdit_0.text(), True))
        self.button_2.clicked.connect(lambda : self.parent().replace( self.lineEdit_0.text(), self.lineEdit_1.text() ))
        self.button_3.clicked.connect(lambda : self.parent().textEdit.replaceAll( self.lineEdit_0.text(), self.lineEdit_1.text() ))
        self.button_4.clicked.connect(lambda : self.reject())
        
class FindDialog(Dialog):
    def __init__(self, parent=None):
        super(FindDialog, self).__init__(parent)
        self.setWindowTitle('Find')
        self.label_1.hide()
        self.lineEdit_1.hide()
        self.button_2.hide()
        self.button_3.hide()

class ReplaceDialog(Dialog):
    def __init__(self, parent=None):
        super(ReplaceDialog, self).__init__(parent)
        self.setWindowTitle('Replace')
        self.button_1.hide()

if __name__ == '__main__':
    app = QtWidgets.QApplication(sys.argv)
    memoPad = MemoPad()
    memoPad.show()
    app.exec()

Share post

Related Posts

コメント