You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

508 lines
21 KiB

  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. Created on Wed May 29 00:02:24 2019
  5. @author: ivan
  6. """
  7. import sys, fitz, io, subprocess, os
  8. from copy import copy
  9. from PyQt5.QtWidgets import QApplication, QAction, QLabel, QDialogButtonBox, QDialog, QFileDialog, QMessageBox, QPushButton, QLineEdit, QCheckBox, QSpinBox, QDoubleSpinBox, QTableWidgetItem, QTabWidget, QComboBox, QWidget, QScrollArea, QMainWindow, QShortcut
  10. from PyQt5.QtCore import QFile, QObject, Qt, pyqtSlot, QSettings
  11. from PyQt5.QtGui import QPixmap, QImage, QKeySequence
  12. from PyQt5 import uic
  13. from chordsheet.tableView import ChordTableView, BlockTableView , MItemModel, MProxyStyle
  14. from reportlab.lib.units import mm, cm, inch, pica
  15. from reportlab.lib.pagesizes import A4, A5, LETTER, LEGAL
  16. from reportlab.pdfbase import pdfmetrics
  17. from reportlab.pdfbase.ttfonts import TTFont
  18. from chordsheet.document import Document, Style, Chord, Block
  19. from chordsheet.render import savePDF
  20. from chordsheet.parsers import parseFingering, parseName
  21. # set the directory where our files are depending on whether we're running a pyinstaller binary or not
  22. if getattr(sys, 'frozen', False):
  23. scriptDir = sys._MEIPASS
  24. else:
  25. scriptDir = os.path.abspath(os.path.dirname(os.path.abspath(__file__)))
  26. QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) # enable automatic high DPI scaling on Windows
  27. QApplication.setOrganizationName("Ivan Holmes")
  28. QApplication.setOrganizationDomain("ivanholmes.co.uk")
  29. QApplication.setApplicationName("Chordsheet")
  30. settings = QSettings()
  31. pdfmetrics.registerFont(TTFont('FreeSans', os.path.join(scriptDir, 'fonts', 'FreeSans.ttf')))
  32. if sys.platform == "darwin":
  33. pdfmetrics.registerFont(TTFont('HelveticaNeue', 'HelveticaNeue.ttc', subfontIndex=0))
  34. # dictionaries for combo boxes
  35. pageSizeDict = {'A4':A4, 'A5':A5, 'Letter':LETTER, 'Legal':LEGAL}
  36. unitDict = {'mm':mm, 'cm':cm, 'inch':inch, 'point':1, 'pica':pica} # point is 1 because reportlab's native unit is points.
  37. class DocumentWindow(QMainWindow):
  38. """
  39. Class for the main window of the application.
  40. """
  41. def __init__(self, doc, style, filename=None):
  42. """
  43. Initialisation function for the main window of the application.
  44. Arguments:
  45. doc -- the Document object for the window to use
  46. style -- the Style object for the window to use
  47. """
  48. super().__init__()
  49. self.doc = doc
  50. self.style = style
  51. self.lastDoc = copy(self.doc)
  52. self.currentFilePath = filename
  53. self.UIFileLoader(str(os.path.join(scriptDir, 'ui','mainwindow.ui')))
  54. self.UIInitStyle()
  55. self.setCentralWidget(self.window.centralWidget)
  56. self.setMenuBar(self.window.menuBar)
  57. self.setWindowTitle("Chordsheet")
  58. if filename:
  59. try:
  60. self.doc.loadXML(filename)
  61. except:
  62. UnreadableMessageBox().exec()
  63. def closeEvent(self, event):
  64. """
  65. Reimplement the built in closeEvent to allow asking the user to save.
  66. """
  67. self.saveWarning()
  68. def UIFileLoader(self, ui_file):
  69. """
  70. Loads the .ui file for this window and connects the UI elements to their actions.
  71. """
  72. ui_file = QFile(ui_file)
  73. ui_file.open(QFile.ReadOnly)
  74. self.window = uic.loadUi(ui_file)
  75. ui_file.close()
  76. # link all the UI elements
  77. self.window.actionAbout.triggered.connect(self.menuFileAboutAction)
  78. self.window.actionNew.triggered.connect(self.menuFileNewAction)
  79. self.window.actionOpen.triggered.connect(self.menuFileOpenAction)
  80. self.window.actionSave.triggered.connect(self.menuFileSaveAction)
  81. self.window.actionSave_as.triggered.connect(self.menuFileSaveAsAction)
  82. self.window.actionSave_PDF.triggered.connect(self.menuFileSavePDFAction)
  83. self.window.actionPrint.triggered.connect(self.menuFilePrintAction)
  84. self.window.actionClose.triggered.connect(self.menuFileCloseAction)
  85. self.window.actionNew.setShortcut(QKeySequence.New)
  86. self.window.actionOpen.setShortcut(QKeySequence.Open)
  87. self.window.actionSave.setShortcut(QKeySequence.Save)
  88. self.window.actionSave_as.setShortcut(QKeySequence.SaveAs)
  89. self.window.actionSave_PDF.setShortcut(QKeySequence("Ctrl+E"))
  90. self.window.actionPrint.setShortcut(QKeySequence.Print)
  91. self.window.actionClose.setShortcut(QKeySequence.Close)
  92. self.window.actionUndo.setShortcut(QKeySequence.Undo)
  93. self.window.actionRedo.setShortcut(QKeySequence.Redo)
  94. self.window.actionCut.setShortcut(QKeySequence.Cut)
  95. self.window.actionCopy.setShortcut(QKeySequence.Copy)
  96. self.window.actionPaste.setShortcut(QKeySequence.Paste)
  97. self.window.pageSizeComboBox.currentIndexChanged.connect(self.pageSizeAction)
  98. self.window.documentUnitsComboBox.currentIndexChanged.connect(self.unitAction)
  99. self.window.includedFontCheckBox.stateChanged.connect(self.includedFontAction)
  100. self.window.generateButton.clicked.connect(self.generateAction)
  101. self.window.guitarVoicingButton.clicked.connect(self.guitarVoicingAction)
  102. self.window.addChordButton.clicked.connect(self.addChordAction)
  103. self.window.removeChordButton.clicked.connect(self.removeChordAction)
  104. self.window.updateChordButton.clicked.connect(self.updateChordAction)
  105. self.window.addBlockButton.clicked.connect(self.addBlockAction)
  106. self.window.removeBlockButton.clicked.connect(self.removeBlockAction)
  107. self.window.updateBlockButton.clicked.connect(self.updateBlockAction)
  108. self.window.chordTableView.clicked.connect(self.chordClickedAction)
  109. self.window.blockTableView.clicked.connect(self.blockClickedAction)
  110. def UIInitDocument(self):
  111. """
  112. Fills the window's fields with the values from its document.
  113. """
  114. self.updateTitleBar()
  115. # set all fields to appropriate values from document
  116. self.window.titleLineEdit.setText(self.doc.title)
  117. self.window.composerLineEdit.setText(self.doc.composer)
  118. self.window.arrangerLineEdit.setText(self.doc.arranger)
  119. self.window.timeSignatureSpinBox.setValue(self.doc.timeSignature)
  120. self.window.chordTableView.populate(self.doc.chordList)
  121. self.window.blockTableView.populate(self.doc.blockList)
  122. self.updateChordDict()
  123. def UIInitStyle(self):
  124. """
  125. Fills the window's fields with the values from its style.
  126. """
  127. self.window.pageSizeComboBox.addItems(list(pageSizeDict.keys()))
  128. self.window.pageSizeComboBox.setCurrentText(list(pageSizeDict.keys())[0])
  129. self.window.documentUnitsComboBox.addItems(list(unitDict.keys()))
  130. self.window.documentUnitsComboBox.setCurrentText(list(unitDict.keys())[0])
  131. self.window.lineSpacingDoubleSpinBox.setValue(self.style.lineSpacing)
  132. self.window.leftMarginLineEdit.setText(str(self.style.leftMargin))
  133. self.window.topMarginLineEdit.setText(str(self.style.topMargin))
  134. self.window.fontComboBox.setDisabled(True)
  135. self.window.includedFontCheckBox.setChecked(True)
  136. def pageSizeAction(self, index):
  137. self.pageSizeSelected = self.window.pageSizeComboBox.itemText(index)
  138. def unitAction(self, index):
  139. self.unitSelected = self.window.documentUnitsComboBox.itemText(index)
  140. def includedFontAction(self):
  141. if self.window.includedFontCheckBox.isChecked():
  142. self.style.useIncludedFont = True
  143. else:
  144. self.style.useIncludedFont = False
  145. def chordClickedAction(self, index):
  146. self.window.chordNameLineEdit.setText(self.window.chordTableView.model.item(index.row(), 0).text())
  147. self.window.guitarVoicingLineEdit.setText(self.window.chordTableView.model.item(index.row(), 1).text())
  148. def blockClickedAction(self, index):
  149. self.window.blockChordComboBox.setCurrentText(self.window.blockTableView.model.item(index.row(), 0).text())
  150. self.window.blockLengthLineEdit.setText(self.window.blockTableView.model.item(index.row(), 1).text())
  151. self.window.blockNotesLineEdit.setText(self.window.blockTableView.model.item(index.row(), 2).text())
  152. def getPath(self, value):
  153. """
  154. Wrapper for Qt settings to return home directory if no setting exists.
  155. """
  156. return str((settings.value(value) if settings.value(value) else os.path.expanduser("~")))
  157. def setPath(self, value, fullpath):
  158. """
  159. Wrapper for Qt settings to set path to open/save from next time from current file location.
  160. """
  161. return settings.setValue(value, os.path.dirname(fullpath))
  162. def menuFileNewAction(self):
  163. self.doc = Document()
  164. self.lastDoc = copy(self.doc)
  165. self.currentFilePath = None
  166. self.UIInitDocument()
  167. self.updatePreview()
  168. def menuFileOpenAction(self):
  169. filePath = QFileDialog.getOpenFileName(self.window.tabWidget, 'Open file', self.getPath("workingPath"), "Chordsheet ML files (*.xml *.cml)")
  170. if filePath[0]:
  171. self.currentFilePath = filePath[0]
  172. self.doc.loadXML(filePath[0])
  173. self.lastDoc = copy(self.doc)
  174. self.setPath("workingPath", self.currentFilePath)
  175. self.UIInitDocument()
  176. self.updatePreview()
  177. def menuFileSaveAction(self):
  178. self.updateDocument()
  179. if not (hasattr(self, 'currentFilePath') and self.currentFilePath):
  180. filePath = QFileDialog.getSaveFileName(self.window.tabWidget, 'Save file', self.getPath("workingPath"), "Chordsheet ML files (*.xml *.cml)")
  181. self.currentFilePath = filePath[0]
  182. self.doc.saveXML(self.currentFilePath)
  183. self.lastDoc = copy(self.doc)
  184. self.setPath("workingPath", self.currentFilePath)
  185. def menuFileSaveAsAction(self):
  186. self.updateDocument()
  187. filePath = QFileDialog.getSaveFileName(self.window.tabWidget, 'Save file', self.getPath("workingPath"), "Chordsheet ML files (*.xml *.cml)")
  188. if filePath[0]:
  189. self.currentFilePath = filePath[0]
  190. self.doc.saveXML(self.currentFilePath)
  191. self.lastDoc = copy(self.doc)
  192. self.setPath("workingPath", self.currentFilePath)
  193. self.updateTitleBar() # as we now have a new filename
  194. def menuFileSavePDFAction(self):
  195. self.updateDocument()
  196. self.updatePreview()
  197. filePath = QFileDialog.getSaveFileName(self.window.tabWidget, 'Save file', self.getPath("lastExportPath"), "PDF files (*.pdf)")
  198. if filePath[0]:
  199. savePDF(d, s, filePath[0])
  200. self.setPath("lastExportPath", filePath[0])
  201. def menuFilePrintAction(self):
  202. if sys.platform == "darwin":
  203. pass
  204. # subprocess.call()
  205. else:
  206. pass
  207. @pyqtSlot()
  208. def menuFileCloseAction(self):
  209. self.saveWarning()
  210. def menuFileAboutAction(self):
  211. aboutDialog = QMessageBox.information(self, "About", "Chordsheet © Ivan Holmes, 2019", buttons = QMessageBox.Ok, defaultButton = QMessageBox.Ok)
  212. def saveWarning(self):
  213. """
  214. Function to check if the document has unsaved data in it and offer to save it.
  215. """
  216. self.updateDocument() # update the document to catch all changes
  217. if (self.lastDoc == self.doc):
  218. self.close()
  219. else:
  220. wantToSave = UnsavedMessageBox().exec()
  221. if wantToSave == QMessageBox.Save:
  222. if not (hasattr(self, 'currentFilePath') and self.currentFilePath):
  223. filePath = QFileDialog.getSaveFileName(self.window.tabWidget, 'Save file', str(os.path.expanduser("~")), "Chordsheet ML files (*.xml *.cml)")
  224. self.currentFilePath = filePath[0]
  225. self.doc.saveXML(self.currentFilePath)
  226. self.close()
  227. elif wantToSave == QMessageBox.Discard:
  228. self.close()
  229. # if cancel or anything else do nothing at all
  230. def guitarVoicingAction(self):
  231. gdialog = GuitarDialog()
  232. voicing = gdialog.getVoicing()
  233. if voicing:
  234. self.window.guitarVoicingLineEdit.setText(voicing)
  235. def clearChordLineEdits(self):
  236. self.window.chordNameLineEdit.clear()
  237. self.window.guitarVoicingLineEdit.clear()
  238. self.window.chordNameLineEdit.repaint() # necessary on Mojave with PyInstaller (or previous contents will be shown)
  239. self.window.guitarVoicingLineEdit.clear()
  240. def updateChordDict(self):
  241. self.chordDict = {c.name:c for c in self.doc.chordList}
  242. self.window.blockChordComboBox.clear()
  243. self.window.blockChordComboBox.addItems(list(self.chordDict.keys()))
  244. def removeChordAction(self):
  245. self.updateChords()
  246. row = self.window.chordTableView.selectionModel().currentIndex().row()
  247. self.doc.chordList.pop(row)
  248. self.window.chordTableView.populate(self.doc.chordList)
  249. self.clearChordLineEdits()
  250. self.updateChordDict()
  251. def addChordAction(self):
  252. self.updateChords()
  253. self.doc.chordList.append(Chord(parseName(self.window.chordNameLineEdit.text())))
  254. if self.window.guitarVoicingLineEdit.text():
  255. self.doc.chordList[-1].voicings['guitar'] = parseFingering(self.window.guitarVoicingLineEdit.text(), 'guitar')
  256. self.window.chordTableView.populate(self.doc.chordList)
  257. self.clearChordLineEdits()
  258. self.updateChordDict()
  259. def updateChordAction(self):
  260. if self.window.chordTableView.selectionModel().hasSelection():
  261. self.updateChords()
  262. row = self.window.chordTableView.selectionModel().currentIndex().row()
  263. self.doc.chordList[row] = Chord(parseName(self.window.chordNameLineEdit.text()))
  264. if self.window.guitarVoicingLineEdit.text():
  265. self.doc.chordList[-1].voicings['guitar'] = parseFingering(self.window.guitarVoicingLineEdit.text(), 'guitar')
  266. self.window.chordTableView.populate(self.doc.chordList)
  267. self.clearChordLineEdits()
  268. self.updateChordDict()
  269. def clearBlockLineEdits(self):
  270. self.window.blockLengthLineEdit.clear()
  271. self.window.blockNotesLineEdit.clear()
  272. self.window.blockLengthLineEdit.repaint() # necessary on Mojave with PyInstaller (or previous contents will be shown)
  273. self.window.blockNotesLineEdit.repaint()
  274. def removeBlockAction(self):
  275. self.updateBlocks()
  276. row = self.window.blockTableView.selectionModel().currentIndex().row()
  277. self.doc.blockList.pop(row)
  278. self.window.blockTableView.populate(self.doc.blockList)
  279. def addBlockAction(self):
  280. self.updateBlocks()
  281. self.doc.blockList.append(Block(self.window.blockLengthLineEdit.text(),
  282. chord = self.chordDict[self.window.blockChordComboBox.currentText()],
  283. notes = (self.window.blockNotesLineEdit.text() if not "" else None)))
  284. self.window.blockTableView.populate(self.doc.blockList)
  285. self.clearBlockLineEdits()
  286. def updateBlockAction(self):
  287. if self.window.blockTableView.selectionModel().hasSelection():
  288. self.updateBlocks()
  289. row = self.window.blockTableView.selectionModel().currentIndex().row()
  290. self.doc.blockList[row] = (Block(self.window.blockLengthLineEdit.text(),
  291. chord = self.chordDict[self.window.blockChordComboBox.currentText()],
  292. notes = (self.window.blockNotesLineEdit.text() if not "" else None)))
  293. self.window.blockTableView.populate(self.doc.blockList)
  294. self.clearBlockLineEdits()
  295. def generateAction(self):
  296. self.updateDocument()
  297. self.updatePreview()
  298. def updatePreview(self):
  299. self.currentPreview = io.BytesIO()
  300. savePDF(self.doc, self.style, self.currentPreview)
  301. pdfView = fitz.Document(stream=self.currentPreview, filetype='pdf')
  302. pix = pdfView[0].getPixmap(alpha = False)
  303. fmt = QImage.Format_RGB888
  304. qtimg = QImage(pix.samples, pix.width, pix.height, pix.stride, fmt)
  305. self.window.imageLabel.setPixmap(QPixmap.fromImage(qtimg))
  306. self.window.imageLabel.repaint() # necessary on Mojave with PyInstaller (or previous contents will be shown)
  307. def updateTitleBar(self):
  308. appName = "Chordsheet"
  309. if self.currentFilePath:
  310. self.setWindowTitle(appName + " – " + os.path.basename(self.currentFilePath))
  311. else:
  312. self.setWindowTitle(appName)
  313. def updateChords(self):
  314. chordTableList = []
  315. for i in range(self.window.chordTableView.model.rowCount()):
  316. chordTableList.append(Chord(parseName(self.window.chordTableView.model.item(i, 0).text()))),
  317. if self.window.chordTableView.model.item(i, 1).text():
  318. chordTableList[-1].voicings['guitar'] = parseFingering(self.window.chordTableView.model.item(i, 1).text(), 'guitar')
  319. self.doc.chordList = chordTableList
  320. def updateBlocks(self):
  321. blockTableList = []
  322. for i in range(self.window.blockTableView.model.rowCount()):
  323. blockLength = int(self.window.blockTableView.model.item(i, 1).text())
  324. blockChordName = parseName(self.window.blockTableView.model.item(i, 0).text())
  325. if blockChordName:
  326. blockChord = None
  327. for c in self.doc.chordList:
  328. if c.name == blockChordName:
  329. blockChord = c
  330. break
  331. if blockChord is None:
  332. exit("Chord {c} does not match any chord in {l}.".format(c=blockChordName, l=self.doc.chordList))
  333. else:
  334. blockChord = None
  335. blockNotes = self.window.blockTableView.model.item(i, 2).text() if self.window.blockTableView.model.item(i, 2).text() else None
  336. blockTableList.append(Block(blockLength, chord=blockChord, notes=blockNotes))
  337. self.doc.blockList = blockTableList
  338. def updateDocument(self):
  339. self.doc.title = self.window.titleLineEdit.text() # Title can be empty string but not None
  340. self.doc.composer = (self.window.composerLineEdit.text() if self.window.composerLineEdit.text() else None)
  341. self.doc.arranger = (self.window.arrangerLineEdit.text() if self.window.arrangerLineEdit.text() else None)
  342. self.doc.timeSignature = int(self.window.timeSignatureSpinBox.value())
  343. self.style.pageSize = pageSizeDict[self.pageSizeSelected]
  344. self.style.unit = unitDict[self.unitSelected]
  345. self.style.leftMargin = int(self.window.leftMarginLineEdit.text())
  346. self.style.topMargin = int(self.window.topMarginLineEdit.text())
  347. self.style.lineSpacing = float(self.window.lineSpacingDoubleSpinBox.value())
  348. self.updateChords()
  349. self.updateBlocks()
  350. self.style.font = ('FreeSans' if self.style.useIncludedFont else 'HelveticaNeue')
  351. # something for the font box here
  352. class GuitarDialog(QDialog):
  353. """
  354. Dialogue to allow the user to enter a guitar chord voicing. Not particularly advanced at present!
  355. May be extended in future.
  356. """
  357. def __init__(self):
  358. super().__init__()
  359. self.UIFileLoader(str(os.path.join(scriptDir, 'ui','guitardialog.ui')))
  360. def UIFileLoader(self, ui_file):
  361. ui_file = QFile(ui_file)
  362. ui_file.open(QFile.ReadOnly)
  363. self.dialog = uic.loadUi(ui_file)
  364. ui_file.close()
  365. def getVoicing(self):
  366. if self.dialog.exec_() == QDialog.Accepted:
  367. result = [self.dialog.ELineEdit.text(),
  368. self.dialog.ALineEdit.text(),
  369. self.dialog.DLineEdit.text(),
  370. self.dialog.GLineEdit.text(),
  371. self.dialog.BLineEdit.text(),
  372. self.dialog.eLineEdit.text()]
  373. resultJoined = ",".join(result)
  374. return resultJoined
  375. else:
  376. return None
  377. class UnsavedMessageBox(QMessageBox):
  378. """
  379. Message box to alert the user of unsaved changes and allow them to choose how to act.
  380. """
  381. def __init__(self):
  382. super().__init__()
  383. self.setWindowTitle("Unsaved changes")
  384. self.setText("The document has been modified.")
  385. self.setInformativeText("Do you want to save your changes?")
  386. self.setStandardButtons(QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel)
  387. self.setDefaultButton(QMessageBox.Save)
  388. class UnreadableMessageBox(QMessageBox):
  389. """
  390. Message box to inform the user that the chosen file cannot be opened.
  391. """
  392. def __init__(self):
  393. super().__init__()
  394. self.setWindowTitle("File cannot be opened")
  395. self.setText("The file you have selected cannot be opened.")
  396. self.setInformativeText("Please make sure it is in the right format.")
  397. self.setStandardButtons(QMessageBox.Ok)
  398. self.setDefaultButton(QMessageBox.Ok)
  399. if __name__ == '__main__':
  400. app = QApplication(sys.argv)
  401. d = Document()
  402. s = Style()
  403. w = DocumentWindow(d, s, filename=(sys.argv[1] if len(sys.argv) > 1 else None)) # pass first argument as filename
  404. w.show()
  405. sys.exit(app.exec_())