From 36a86d280f8db36d9d5157099434840f18c680f6 Mon Sep 17 00:00:00 2001 From: Ivan Holmes Date: Mon, 4 Nov 2019 00:24:21 +0000 Subject: [PATCH] various modifications and improvements: show name of document in title bar add keyboard shortcuts improve UI resizability make close menu item functional add primitive about dialog add a warning if the user tries to close without saving make chords, blocks, document equatable remember where the user last saved/opened a document --- README.md | 1 + chordsheet/document.py | 32 +++++-- chordsheet/render.py | 8 +- chordsheet/tableView.py | 2 +- gui.py | 196 +++++++++++++++++++++++++++++++++++----- ui/guitardialog.ui | 2 +- ui/mainwindow.ui | 97 ++++++++++++++------ 7 files changed, 274 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 9bb3dc5..0611a7a 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Chordsheet is alpha-grade software. At present, the program will crash readily g - PDF preview is blurry on high DPI monitors - Chord names and notes can spill out of their block if it's not big enough - Poor font handling (choice of either FreeSans or Helvetica Neue if installed) +- No support for printing ## Dependencies Chordsheet depends on pymupdf (to show the preview), reportlab (to generate the PDF), and PyQt5 (for the GUI). diff --git a/chordsheet/document.py b/chordsheet/document.py index 03a4968..850dc43 100644 --- a/chordsheet/document.py +++ b/chordsheet/document.py @@ -35,8 +35,15 @@ class Style: class Chord: def __init__(self, name, **kwargs): self.name = name + self.voicings = {} for inst, fing in kwargs.items(): - setattr(self, inst, fing) + self.voicings[inst] = fing + + def __eq__(self, other): + if isinstance(other, self.__class__): + return self.name == other.name and self.voicings == other.voicings + return NotImplemented + class Block: def __init__(self, length, **kwargs): @@ -44,6 +51,11 @@ class Block: self.chord = kwargs.get('chord', None) self.notes = kwargs.get('notes', None) + def __eq__(self, other): + if isinstance(other, self.__class__): + return self.length == other.length and self.chord == other.chord and self.notes == other.notes + return NotImplemented + class Document: def __init__(self, chordList=None, blockList=None, title=None, composer=None, arranger=None, timeSignature=defaultTimeSignature): self.chordList = chordList or [] @@ -52,6 +64,12 @@ class Document: self.composer = composer self.arranger = arranger self.timeSignature = timeSignature + + def __eq__(self, other): + if isinstance(other, self.__class__): + textEqual = self.title == other.title and self.composer == other.composer and self.arranger == other.arranger and self.timeSignature == other.timeSignature # check all the text values for equality + return textEqual and self.chordList == other.chordList and self.blockList == other.blockList + return NotImplemented def loadXML(self, filepath): xmlDoc = ET.parse(filepath) @@ -62,8 +80,7 @@ class Document: for c in root.findall('chords/chord'): self.chordList.append(Chord(parseName(c.find('name').text))) for v in c.findall('voicing'): - setattr(self.chordList[-1], v.attrib['instrument'], - parseFingering(v.text, v.attrib['instrument'])) + self.chordList[-1].voicings[v.attrib['instrument']] = parseFingering(v.text, v.attrib['instrument']) self.blockList = [] if root.find('progression') is not None: @@ -110,10 +127,11 @@ class Document: for c in self.chordList: chordElement = ET.SubElement(chordsElement, "chord") ET.SubElement(chordElement, "name").text = c.name - if hasattr(c, 'guitar'): - ET.SubElement(chordElement, "voicing", attrib={'instrument':'guitar'}).text = ','.join(c.guitar) - if hasattr(c, 'piano'): - ET.SubElement(chordElement, "voicing", attrib={'instrument':'piano'}).text = c.piano[0] # return first element of list as feature has not been implemented + for inst in c.voicings.keys(): + if inst == 'guitar': + ET.SubElement(chordElement, "voicing", attrib={'instrument':'guitar'}).text = ','.join(c.voicings['guitar']) + if inst == 'piano': + ET.SubElement(chordElement, "voicing", attrib={'instrument':'piano'}).text = c.voicings['piano'][0] # return first element of list as feature has not been implemented progressionElement = ET.SubElement(root, "progression") diff --git a/chordsheet/render.py b/chordsheet/render.py index cfc9a69..aebf210 100644 --- a/chordsheet/render.py +++ b/chordsheet/render.py @@ -48,9 +48,9 @@ def guitarChart(currentCanvas, style, chordList, cur_pos): nstrings = 6 fontsize = 12 - guitarChordList = [[chordList[q].guitar[-(r+1)] for q in range(len(chordList)) if hasattr(chordList[q], 'guitar')] for r in range(6)] - guitarChordList.append([chordList[q].name for q in range(len(chordList)) if hasattr(chordList[q], 'guitar')]) - + guitarChordList = [[chordList[q].voicings['guitar'][-(r+1)] for q in range(len(chordList)) if 'guitar' in chordList[q].voicings.keys()] for r in range(nstrings)] + guitarChordList.append([chordList[q].name for q in range(len(chordList)) if 'guitar' in chordList[q].voicings.keys()]) + for i in range(nstrings+1): # i is the string currently being drawn writeText(currentCanvas, style, ['e','B','G','D','A','E','Name'][i], fontsize, v_origin+(i*string_height), hpos=h_origin, align='right') @@ -126,7 +126,7 @@ def chordProgression(currentCanvas, style, document, cur_pos): def guitarChartCheck(cL): chordsPresent = False for c in cL: - if hasattr(c, 'guitar'): + if 'guitar' in c.voicings.keys(): chordsPresent = True break return chordsPresent diff --git a/chordsheet/tableView.py b/chordsheet/tableView.py index 4144299..de0ef29 100644 --- a/chordsheet/tableView.py +++ b/chordsheet/tableView.py @@ -55,7 +55,7 @@ class ChordTableView(MTableView): def populate(self, cList): self.model.removeRows(0, self.model.rowCount()) for c in cList: - rowList = [QtGui.QStandardItem(c.name), QtGui.QStandardItem(",".join(c.guitar if hasattr(c, 'guitar') else ""))] + rowList = [QtGui.QStandardItem(c.name), QtGui.QStandardItem(",".join(c.voicings['guitar'] if 'guitar' in c.voicings.keys() else ""))] for item in rowList: item.setEditable(False) item.setDropEnabled(False) diff --git a/gui.py b/gui.py index 50ce4f8..8ea6fe5 100644 --- a/gui.py +++ b/gui.py @@ -7,10 +7,11 @@ Created on Wed May 29 00:02:24 2019 """ import sys, fitz, io, subprocess, os +from copy import copy -from PyQt5.QtWidgets import QApplication, QAction, QLabel, QDialogButtonBox, QDialog, QFileDialog, QMessageBox, QPushButton, QLineEdit, QCheckBox, QSpinBox, QDoubleSpinBox, QTableWidget, QTableWidgetItem, QTabWidget, QComboBox, QWidget, QScrollArea -from PyQt5.QtCore import QFile, QObject, Qt -from PyQt5.QtGui import QPixmap, QImage +from PyQt5.QtWidgets import QApplication, QAction, QLabel, QDialogButtonBox, QDialog, QFileDialog, QMessageBox, QPushButton, QLineEdit, QCheckBox, QSpinBox, QDoubleSpinBox, QTableWidgetItem, QTabWidget, QComboBox, QWidget, QScrollArea, QMainWindow, QShortcut +from PyQt5.QtCore import QFile, QObject, Qt, pyqtSlot, QSettings +from PyQt5.QtGui import QPixmap, QImage, QKeySequence from PyQt5 import uic from chordsheet.tableView import ChordTableView, BlockTableView , MItemModel, MProxyStyle @@ -23,38 +24,78 @@ from chordsheet.document import Document, Style, Chord, Block from chordsheet.render import savePDF from chordsheet.parsers import parseFingering, parseName +# set the directory where our files are depending on whether we're running a pyinstaller binary or not if getattr(sys, 'frozen', False): scriptDir = sys._MEIPASS else: scriptDir = os.path.abspath(os.path.dirname(os.path.abspath(__file__))) -QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) +QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) # enable automatic high DPI scaling on Windows +QApplication.setOrganizationName("Ivan Holmes") +QApplication.setOrganizationDomain("ivanholmes.co.uk") +QApplication.setApplicationName("Chordsheet") +settings = QSettings() pdfmetrics.registerFont(TTFont('FreeSans', os.path.join(scriptDir, 'fonts', 'FreeSans.ttf'))) if sys.platform == "darwin": pdfmetrics.registerFont(TTFont('HelveticaNeue', 'HelveticaNeue.ttc', subfontIndex=0)) +# dictionaries for combo boxes pageSizeDict = {'A4':A4, 'A5':A5, 'Letter':LETTER, 'Legal':LEGAL} unitDict = {'mm':mm, 'cm':cm, 'inch':inch, 'point':1, 'pica':pica} # point is 1 because reportlab's native unit is points. -class DocumentWindow(QWidget): - def __init__(self, doc, style, parent=None): - super().__init__(parent) +class DocumentWindow(QMainWindow): + """ + Class for the main window of the application. + """ + def __init__(self, doc, style, filename=None): + """ + Initialisation function for the main window of the application. + + Arguments: + doc -- the Document object for the window to use + style -- the Style object for the window to use + """ + super().__init__() self.doc = doc self.style = style - + + self.lastDoc = copy(self.doc) + self.currentFilePath = filename + self.UIFileLoader(str(os.path.join(scriptDir, 'ui','mainwindow.ui'))) self.UIInitStyle() - # self.UIInitDocument() + + self.setCentralWidget(self.window.centralWidget) + self.setMenuBar(self.window.menuBar) + self.setWindowTitle("Chordsheet") + + if filename: + try: + self.doc.loadXML(filename) + except: + UnreadableMessageBox().exec() + + def closeEvent(self, event): + """ + Reimplement the built in closeEvent to allow asking the user to save. + """ + self.saveWarning() def UIFileLoader(self, ui_file): + """ + Loads the .ui file for this window and connects the UI elements to their actions. + """ ui_file = QFile(ui_file) ui_file.open(QFile.ReadOnly) self.window = uic.loadUi(ui_file) ui_file.close() + # link all the UI elements + self.window.actionAbout.triggered.connect(self.menuFileAboutAction) + self.window.actionNew.triggered.connect(self.menuFileNewAction) self.window.actionOpen.triggered.connect(self.menuFileOpenAction) self.window.actionSave.triggered.connect(self.menuFileSaveAction) @@ -62,7 +103,20 @@ class DocumentWindow(QWidget): self.window.actionSave_PDF.triggered.connect(self.menuFileSavePDFAction) self.window.actionPrint.triggered.connect(self.menuFilePrintAction) self.window.actionClose.triggered.connect(self.menuFileCloseAction) - + + self.window.actionNew.setShortcut(QKeySequence.New) + self.window.actionOpen.setShortcut(QKeySequence.Open) + self.window.actionSave.setShortcut(QKeySequence.Save) + self.window.actionSave_as.setShortcut(QKeySequence.SaveAs) + self.window.actionSave_PDF.setShortcut(QKeySequence("Ctrl+E")) + self.window.actionPrint.setShortcut(QKeySequence.Print) + self.window.actionClose.setShortcut(QKeySequence.Close) + self.window.actionUndo.setShortcut(QKeySequence.Undo) + self.window.actionRedo.setShortcut(QKeySequence.Redo) + self.window.actionCut.setShortcut(QKeySequence.Cut) + self.window.actionCopy.setShortcut(QKeySequence.Copy) + self.window.actionPaste.setShortcut(QKeySequence.Paste) + self.window.pageSizeComboBox.currentIndexChanged.connect(self.pageSizeAction) self.window.documentUnitsComboBox.currentIndexChanged.connect(self.unitAction) @@ -83,21 +137,25 @@ class DocumentWindow(QWidget): self.window.blockTableView.clicked.connect(self.blockClickedAction) def UIInitDocument(self): + """ + Fills the window's fields with the values from its document. + """ + self.updateTitleBar() + # set all fields to appropriate values from document self.window.titleLineEdit.setText(self.doc.title) self.window.composerLineEdit.setText(self.doc.composer) self.window.arrangerLineEdit.setText(self.doc.arranger) self.window.timeSignatureSpinBox.setValue(self.doc.timeSignature) - # chord and block table lists here self.window.chordTableView.populate(self.doc.chordList) self.window.blockTableView.populate(self.doc.blockList) self.updateChordDict() - - self.window.tabWidget.setCurrentWidget(self.window.tabWidget.findChild(QWidget, 'Overview')) - # self.updatePreview() def UIInitStyle(self): + """ + Fills the window's fields with the values from its style. + """ self.window.pageSizeComboBox.addItems(list(pageSizeDict.keys())) self.window.pageSizeComboBox.setCurrentText(list(pageSizeDict.keys())[0]) @@ -133,37 +191,61 @@ class DocumentWindow(QWidget): self.window.blockLengthLineEdit.setText(self.window.blockTableView.model.item(index.row(), 1).text()) self.window.blockNotesLineEdit.setText(self.window.blockTableView.model.item(index.row(), 2).text()) + def getPath(self, value): + """ + Wrapper for Qt settings to return home directory if no setting exists. + """ + return str((settings.value(value) if settings.value(value) else os.path.expanduser("~"))) + + def setPath(self, value, fullpath): + """ + Wrapper for Qt settings to set path to open/save from next time from current file location. + """ + return settings.setValue(value, os.path.dirname(fullpath)) + def menuFileNewAction(self): self.doc = Document() + self.lastDoc = copy(self.doc) + self.currentFilePath = None + self.UIInitDocument() + self.updatePreview() def menuFileOpenAction(self): - filePath = QFileDialog.getOpenFileName(self.window.tabWidget, 'Open file', str(os.path.expanduser("~")), "Chordsheet ML files (*.xml *.cml)") + filePath = QFileDialog.getOpenFileName(self.window.tabWidget, 'Open file', self.getPath("workingPath"), "Chordsheet ML files (*.xml *.cml)") if filePath[0]: self.currentFilePath = filePath[0] self.doc.loadXML(filePath[0]) + self.lastDoc = copy(self.doc) + self.setPath("workingPath", self.currentFilePath) self.UIInitDocument() self.updatePreview() def menuFileSaveAction(self): self.updateDocument() if not (hasattr(self, 'currentFilePath') and self.currentFilePath): - filePath = QFileDialog.getSaveFileName(self.window.tabWidget, 'Save file', str(os.path.expanduser("~")), "Chordsheet ML files (*.xml *.cml)") + filePath = QFileDialog.getSaveFileName(self.window.tabWidget, 'Save file', self.getPath("workingPath"), "Chordsheet ML files (*.xml *.cml)") self.currentFilePath = filePath[0] self.doc.saveXML(self.currentFilePath) + self.lastDoc = copy(self.doc) + self.setPath("workingPath", self.currentFilePath) def menuFileSaveAsAction(self): self.updateDocument() - filePath = QFileDialog.getSaveFileName(self.window.tabWidget, 'Save file', str(os.path.expanduser("~")), "Chordsheet ML files (*.xml *.cml)") + filePath = QFileDialog.getSaveFileName(self.window.tabWidget, 'Save file', self.getPath("workingPath"), "Chordsheet ML files (*.xml *.cml)") if filePath[0]: self.currentFilePath = filePath[0] self.doc.saveXML(self.currentFilePath) + self.lastDoc = copy(self.doc) + self.setPath("workingPath", self.currentFilePath) + self.updateTitleBar() # as we now have a new filename def menuFileSavePDFAction(self): self.updateDocument() self.updatePreview() - filePath = QFileDialog.getSaveFileName(self.window.tabWidget, 'Save file', str(os.path.expanduser("~")), "PDF files (*.pdf)") + filePath = QFileDialog.getSaveFileName(self.window.tabWidget, 'Save file', self.getPath("lastExportPath"), "PDF files (*.pdf)") if filePath[0]: savePDF(d, s, filePath[0]) + self.setPath("lastExportPath", filePath[0]) def menuFilePrintAction(self): if sys.platform == "darwin": @@ -171,9 +253,35 @@ class DocumentWindow(QWidget): # subprocess.call() else: pass - + + @pyqtSlot() def menuFileCloseAction(self): - pass + self.saveWarning() + + def menuFileAboutAction(self): + aboutDialog = QMessageBox.information(self, "About", "Chordsheet © Ivan Holmes, 2019", buttons = QMessageBox.Ok, defaultButton = QMessageBox.Ok) + + def saveWarning(self): + """ + Function to check if the document has unsaved data in it and offer to save it. + """ + self.updateDocument() # update the document to catch all changes + + if (self.lastDoc == self.doc): + self.close() + else: + wantToSave = UnsavedMessageBox().exec() + + if wantToSave == QMessageBox.Save: + if not (hasattr(self, 'currentFilePath') and self.currentFilePath): + filePath = QFileDialog.getSaveFileName(self.window.tabWidget, 'Save file', str(os.path.expanduser("~")), "Chordsheet ML files (*.xml *.cml)") + self.currentFilePath = filePath[0] + self.doc.saveXML(self.currentFilePath) + self.close() + + elif wantToSave == QMessageBox.Discard: + self.close() + # if cancel or anything else do nothing at all def guitarVoicingAction(self): gdialog = GuitarDialog() @@ -208,7 +316,7 @@ class DocumentWindow(QWidget): self.doc.chordList.append(Chord(parseName(self.window.chordNameLineEdit.text()))) if self.window.guitarVoicingLineEdit.text(): - setattr(self.doc.chordList[-1], 'guitar', parseFingering(self.window.guitarVoicingLineEdit.text(), 'guitar')) + self.doc.chordList[-1].voicings['guitar'] = parseFingering(self.window.guitarVoicingLineEdit.text(), 'guitar') self.window.chordTableView.populate(self.doc.chordList) self.clearChordLineEdits() @@ -220,7 +328,7 @@ class DocumentWindow(QWidget): row = self.window.chordTableView.selectionModel().currentIndex().row() self.doc.chordList[row] = Chord(parseName(self.window.chordNameLineEdit.text())) if self.window.guitarVoicingLineEdit.text(): - setattr(self.doc.chordList[row], 'guitar', parseFingering(self.window.guitarVoicingLineEdit.text(), 'guitar')) + self.doc.chordList[-1].voicings['guitar'] = parseFingering(self.window.guitarVoicingLineEdit.text(), 'guitar') self.window.chordTableView.populate(self.doc.chordList) self.clearChordLineEdits() @@ -277,13 +385,20 @@ class DocumentWindow(QWidget): self.window.imageLabel.setPixmap(QPixmap.fromImage(qtimg)) self.window.imageLabel.repaint() # necessary on Mojave with PyInstaller (or previous contents will be shown) + + def updateTitleBar(self): + appName = "Chordsheet" + if self.currentFilePath: + self.setWindowTitle(appName + " – " + os.path.basename(self.currentFilePath)) + else: + self.setWindowTitle(appName) def updateChords(self): chordTableList = [] for i in range(self.window.chordTableView.model.rowCount()): chordTableList.append(Chord(parseName(self.window.chordTableView.model.item(i, 0).text()))), if self.window.chordTableView.model.item(i, 1).text(): - chordTableList[-1].guitar = parseFingering(self.window.chordTableView.model.item(i, 1).text(), 'guitar') + chordTableList[-1].voicings['guitar'] = parseFingering(self.window.chordTableView.model.item(i, 1).text(), 'guitar') self.doc.chordList = chordTableList @@ -326,6 +441,10 @@ class DocumentWindow(QWidget): # something for the font box here class GuitarDialog(QDialog): + """ + Dialogue to allow the user to enter a guitar chord voicing. Not particularly advanced at present! + May be extended in future. + """ def __init__(self): super().__init__() self.UIFileLoader(str(os.path.join(scriptDir, 'ui','guitardialog.ui'))) @@ -350,13 +469,40 @@ class GuitarDialog(QDialog): else: return None +class UnsavedMessageBox(QMessageBox): + """ + Message box to alert the user of unsaved changes and allow them to choose how to act. + """ + def __init__(self): + super().__init__() + + self.setWindowTitle("Unsaved changes") + self.setText("The document has been modified.") + self.setInformativeText("Do you want to save your changes?") + self.setStandardButtons(QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel) + self.setDefaultButton(QMessageBox.Save) + +class UnreadableMessageBox(QMessageBox): + """ + Message box to inform the user that the chosen file cannot be opened. + """ + def __init__(self): + super().__init__() + + self.setWindowTitle("File cannot be opened") + self.setText("The file you have selected cannot be opened.") + self.setInformativeText("Please make sure it is in the right format.") + self.setStandardButtons(QMessageBox.Ok) + self.setDefaultButton(QMessageBox.Ok) + + if __name__ == '__main__': app = QApplication(sys.argv) d = Document() s = Style() - w = DocumentWindow(d, s) - w.window.show() + w = DocumentWindow(d, s, filename=(sys.argv[1] if len(sys.argv) > 1 else None)) # pass first argument as filename + w.show() sys.exit(app.exec_()) diff --git a/ui/guitardialog.ui b/ui/guitardialog.ui index 68c1c78..20bf569 100644 --- a/ui/guitardialog.ui +++ b/ui/guitardialog.ui @@ -23,7 +23,7 @@ - Guitar chord + Guitar chord editor diff --git a/ui/mainwindow.ui b/ui/mainwindow.ui index 029b78b..69370f3 100644 --- a/ui/mainwindow.ui +++ b/ui/mainwindow.ui @@ -6,14 +6,20 @@ 0 0 - 1113 - 746 + 761 + 513 Chordsheet - + + false + + + QTabWidget::Rounded + + @@ -24,16 +30,19 @@ Qt::Horizontal + + true + - + 0 0 - 320 - 0 + 430 + 400 @@ -51,17 +60,11 @@ - + 0 0 - - - 300 - 400 - - 500 @@ -69,7 +72,7 @@ - 2 + 0 @@ -516,12 +519,6 @@ 0 - - - 0 - 200 - - QAbstractItemView::NoEditTriggers @@ -614,6 +611,9 @@ Qt::Horizontal + + QSizePolicy::MinimumExpanding + 40 @@ -719,6 +719,12 @@ + + + 1 + 0 + + 300 @@ -733,8 +739,8 @@ 0 0 - 580 - 698 + 298 + 465 @@ -776,12 +782,12 @@ - + 0 0 - 1113 + 761 22 @@ -800,7 +806,21 @@ + + + Edit + + + + + + + + + + + @@ -832,6 +852,26 @@ Close + + + Save as... + + + + + Quit + + + + + Undo + + + + + Redo + + Cut @@ -847,9 +887,14 @@ Paste - + - Save as... + Preferences + + + + + About