A universal document converter. Can be used to convert markdown to a wide range of other + description = safeTranslate(qApp, "Export", """
A universal document converter. Can be used to convert Markdown to a wide range of other formats.
Website: http://pandoc.org/
""") @@ -48,7 +50,14 @@ class pandocExporter(basicExporter): return "" def convert(self, src, args, outputfile=None): - args = [self.cmd] + args + if self.isValid() == 2: + run = self.cmd + elif self.isValid() == 1: + run = self.customPath + else: + LOGGER.error("No command for pandoc.") + return None + args = [run] + args if outputfile: args.append("--output={}".format(outputfile)) @@ -67,8 +76,11 @@ class pandocExporter(basicExporter): if var and item and item.text().strip(): args.append("--variable={}:{}".format(var, item.text().strip())) - # Add title metatadata required for pandoc >= 2.x - args.append("--metadata=title:{}".format(mainWindow().mdlFlatData.item(0, 0).text().strip())) + # Add title metadata required for pandoc >= 2.x + title = "Untitled" + if mainWindow().mdlFlatData.item(0, 0): + title = mainWindow().mdlFlatData.item(0, 0).text().strip() + args.append("--metadata=title:{}".format(title)) qApp.setOverrideCursor(QCursor(Qt.WaitCursor)) @@ -87,12 +99,16 @@ class pandocExporter(basicExporter): qApp.restoreOverrideCursor() if stderr or p.returncode != 0: - err = "ERROR on export" + "\n" \ - + "Return code" + ": %d\n" % (p.returncode) \ - + "Command and parameters" + ":\n%s\n" % (p.args) \ - + "Stderr content" + ":\n" + stderr.decode("utf-8") - print(err) - QMessageBox.critical(mainWindow().dialog, qApp.translate("Export", "Error"), err) + err_type = "ERROR" if p.returncode != 0 else "WARNING" + err = "%s on export\n" % err_type \ + + "Return code: %d\n" % p.returncode \ + + "Command and parameters:\n%s\n" % p.args \ + + "Stderr content:\n" + stderr.decode("utf-8") + if p.returncode != 0: + LOGGER.error(err) + QMessageBox.critical(mainWindow().dialog, safeTranslate(qApp, "Export", "Error"), err) + else: + LOGGER.warning(err) return None return stdout.decode("utf-8") diff --git a/manuskript/exporter/pandoc/abstractOutput.py b/manuskript/exporter/pandoc/abstractOutput.py index 880d319..506b0fa 100644 --- a/manuskript/exporter/pandoc/abstractOutput.py +++ b/manuskript/exporter/pandoc/abstractOutput.py @@ -10,6 +10,7 @@ class abstractOutput(abstractPlainText): toFormat = "SUBCLASSME" icon = "SUBCLASSME" exportFilter = "SUBCLASSME" + exportDefaultSuffix = ".SUBCLASSME" requires = { "Settings": True, "Preview": False, diff --git a/manuskript/exporter/pandoc/abstractPlainText.py b/manuskript/exporter/pandoc/abstractPlainText.py index 1fd277e..719d651 100644 --- a/manuskript/exporter/pandoc/abstractPlainText.py +++ b/manuskript/exporter/pandoc/abstractPlainText.py @@ -7,6 +7,10 @@ from PyQt5.QtWidgets import qApp, QVBoxLayout, QCheckBox, QWidget, QHBoxLayout, from manuskript.exporter.manuskript.markdown import markdown, markdownSettings from manuskript.ui.collapsibleGroupBox2 import collapsibleGroupBox2 +from manuskript.functions import safeTranslate + +import logging +LOGGER = logging.getLogger(__name__) class abstractPlainText(markdown): @@ -16,19 +20,22 @@ class abstractPlainText(markdown): toFormat = "SUBCLASSME" icon = "SUBCLASSME" exportFilter = "SUBCLASSME" + exportDefaultSuffix = ".SUBCLASSME" def __init__(self, exporter): self.exporter = exporter def settingsWidget(self): # Get pandoc major version to determine valid command line options - p = re.compile(r'pandoc (\d+)\..*') + p = re.compile(r'pandoc (\d+)\.(\d+).*') m = p.match(self.exporter.version()) if m: majorVersion = m.group(1) + minorVersion = m.group(2) else: majorVersion = "" - w = pandocSettings(self, majorVersion, toFormat=self.toFormat) + minorVersion = "" + w = pandocSettings(self, majorVersion, minorVersion, toFormat=self.toFormat) w.loadSettings() return w @@ -55,8 +62,35 @@ class abstractPlainText(markdown): previewWidget.setPlainText(r) +def versionAsInt(version): + if version is None: + return 0 + + try: + return int(version) + except ValueError: + return 0 + + +def versionToIntArray(version): + if version is None: + return [0, 0] + + p = re.compile(r'(\d+)\.(\d+).*') + m = p.match(version) + if m: + majorVersion = m.group(1) + minorVersion = m.group(2) + else: + majorVersion = "" + minorVersion = "" + + return [ versionAsInt(majorVersion), versionAsInt(minorVersion) ] + + class pandocSetting: - def __init__(self, arg, type, format, label, widget=None, default=None, min=None, max=None, vals=None, suffix=""): + def __init__(self, arg, type, format, label, widget=None, default=None, min=None, max=None, vals=None, suffix="", + minVersion=None, maxVersion=None, specific=False, toc=False): self.arg = arg # start with EXT for extensions self.type = type self.label = label @@ -69,12 +103,16 @@ class pandocSetting: self.max = max self.vals = vals.split("|") if vals else [] self.suffix = suffix + self.minVersion = versionToIntArray(minVersion) + self.maxVersion = versionToIntArray(maxVersion) + self.specific = specific + self.toc = toc def isValid(self, format): """Return whether the specific setting is active with the given format.""" # Empty formats means all - if self.formats is "": + if self.formats == "": return True # "html" in "html markdown latex" @@ -87,120 +125,143 @@ class pandocSetting: return False + def isCompatible(self, majorVersion, minorVersion): + majorNumber = versionAsInt(majorVersion) + minorNumber = versionAsInt(minorVersion) + + if (majorNumber < self.minVersion[0]) or ((majorNumber == self.minVersion[0]) and + (minorNumber < self.minVersion[1])): + return False + + if (self.maxVersion[0] == 0) and (self.maxVersion[1] == 0): + return True + + return (majorNumber < self.maxVersion[0]) or ((majorNumber == self.maxVersion[0]) and + (minorNumber <= self.maxVersion[1])) + + def isSpecific(self): + return self.specific + + def isTOC(self): + return self.toc + class pandocSettings(markdownSettings): settingsList = { # General "standalone": pandocSetting("--standalone", "checkbox", "", - qApp.translate("Export", "Standalone document (not just a fragment)"), + safeTranslate(qApp, "Export", "Standalone document (not just a fragment)"), default=True), "TOC": pandocSetting("--toc", "checkbox", "", - qApp.translate("Export", "Include a table of contents.")), + safeTranslate(qApp, "Export", "Include a table of contents."), toc=True), "TOC-depth": pandocSetting("--toc-depth=", "number", "", - qApp.translate("Export", "Number of sections level to include in TOC: "), - default=3, min=1, max=6), + safeTranslate(qApp, "Export", "Number of sections level to include in TOC: "), + default=3, min=1, max=6, toc=True, minVersion="1.10"), # pandoc v1 only "smart": pandocSetting("--smart", "checkbox", "", - qApp.translate("Export", "Typographically correct output")), + safeTranslate(qApp, "Export", "Typographically correct output"), + maxVersion="1.19.2.4"), # pandoc v1 only "normalize": pandocSetting("--normalize", "checkbox", "", - qApp.translate("Export", "Normalize the document (cleaner)")), - "base-header": pandocSetting("--base-header-level=", "number", "", - qApp.translate("Export", "Specify the base level for headers: "), - default=1, min=1), + safeTranslate(qApp, "Export", "Normalize the document (cleaner)"), + minVersion="1.8", maxVersion="1.19.2.4"), + # pandoc v1.5 to 2.7.3 + "base-header": pandocSetting("--base-header-level=", "number", "", + safeTranslate(qApp, "Export", "Specify the base level for headers: "), + default=1, min=1, minVersion="1.5", maxVersion="2.7.3"), + # pandoc v2.8+ + "shift-heading": pandocSetting("--shift-heading-level-by=", "number", "", + safeTranslate(qApp, "Export", "Specify the base level for headers: "), + default=0, min=0, minVersion="2.8"), "disable-YAML": pandocSetting("EXT-yaml_metadata_block", "checkbox", "", - qApp.translate("Export", "Disable YAML metadata block.\nUse that if you get YAML related error.")), + safeTranslate(qApp, "Export", "Disable YAML metadata block.\nUse that if you get YAML related error."), + minVersion="1.12"), + "hard-line-breaks": pandocSetting("EXT-hard_line_block", "checkbox", "", + safeTranslate(qApp, "Export", "Enable the support on markdown for line break on new line."), + minVersion="1.16"), # Specific "ref-link": pandocSetting("--reference-links", "checkbox", "markdown rst", - qApp.translate("Export", "Use reference-style links instead of inline links")), + safeTranslate(qApp, "Export", "Use reference-style links instead of inline links"), + specific=True), + # pandoc v1.9 to v2.11.1 "atx": pandocSetting("--atx-headers", "checkbox", "markdown asciidoc", - qApp.translate("Export", "Use ATX-style headers")), + safeTranslate(qApp, "Export", "Use ATX-style headers"), specific=True, + minVersion="1.9", maxVersion="2.11.1"), + # pandoc v2.11.2+ + "atx-heading": pandocSetting("--markdown-headings=atx|setext", "checkbox", "markdown asciidoc", + safeTranslate(qApp, "Export", "Use ATX-style headers"), specific=True, + minVersion="2.11.2"), "self-contained": pandocSetting("--self-contained", "checkbox", "html", - qApp.translate("Export", "Self-contained HTML files, with no dependencies")), + safeTranslate(qApp, "Export", "Self-contained HTML files, with no dependencies"), + specific=True, minVersion="1.9"), "q-tags": pandocSetting("--html-q-tags", "checkbox", "html", - qApp.translate("Export", "Usetags for quotes in HTML")), + safeTranslate(qApp, "Export", "Usetags for quotes in HTML"), specific=True, + minVersion="1.10"), # pandoc v1 only "latex-engine": pandocSetting("--latex-engine=", "combo", "pdf", - qApp.translate("Export", "LaTeX engine used to produce the PDF."), - vals="pdflatex|lualatex|xelatex"), + safeTranslate(qApp, "Export", "LaTeX engine used to produce the PDF."), + vals="pdflatex|lualatex|xelatex", specific=True, + minVersion="1.9", maxVersion="1.19.2.4"), # pandoc v2 "pdf-engine": pandocSetting("--pdf-engine=", "combo", "pdf", - qApp.translate("Export", "LaTeX engine used to produce the PDF."), - vals="pdflatex|lualatex|xelatex"), + safeTranslate(qApp, "Export", "LaTeX engine used to produce the PDF."), + vals="pdflatex|lualatex|xelatex", minVersion="2.0", specific=True), "epub3": pandocSetting("EXTepub3", "checkbox", "epub", - qApp.translate("Export", "Convert to ePUB3")), - } - - pdfSettings = { + safeTranslate(qApp, "Export", "Convert to ePUB3"), specific=True, + minVersion="1.10"), # PDF "latex-ps": pandocSetting("--variable=papersize:", "combo", "pdf latex", # FIXME: does not work with default template - qApp.translate("Export", "Paper size:"), - vals="letter|A4|A5"), + safeTranslate(qApp, "Export", "Paper size:"), + vals="letter|A4|A5", specific=True, minVersion="1.4"), "latex-fs": pandocSetting("--variable=fontsize:", "number", "pdf latex", # FIXME: does not work with default template - qApp.translate("Export", "Font size:"), - min=8, max=88, default=12, suffix="pt"), + safeTranslate(qApp, "Export", "Font size:"), + min=8, max=88, default=12, suffix="pt", specific=True, minVersion="1.4"), "latex-class": pandocSetting("--variable=documentclass:", "combo", "pdf latex", - qApp.translate("Export", "Class:"), - vals="article|report|book|memoir"), + safeTranslate(qApp, "Export", "Class:"), + vals="article|report|book|memoir", specific=True, minVersion="1.4"), "latex-ls": pandocSetting("--variable=linestretch:", "combo", "pdf latex", - qApp.translate("Export", "Line spacing:"), - vals="1|1.25|1.5|2"), + safeTranslate(qApp, "Export", "Line spacing:"), + vals="1|1.25|1.5|2", specific=True, minVersion="1.4"), # FIXME: complete with http://pandoc.org/README.html#variables-for-latex } - def __init__(self, _format, majorVersion="", toFormat=None, parent=None): + def __init__(self, _format, majorVersion="", minorVersion="", toFormat=None, parent=None): markdownSettings.__init__(self, _format, parent) self.format = toFormat self.majorVersion = majorVersion + self.minorVersion = minorVersion + + dropSettings = [] + + for key, setting in self.settingsList.items(): + if not setting.isCompatible(self.majorVersion, self.minorVersion): + dropSettings.append(key) + + LOGGER.info(f'Using pandoc settings: {self.majorVersion}.{self.minorVersion}, dropping: {dropSettings}') + + for key in dropSettings: + self.settingsList.pop(key, None) w = QWidget(self) w.setLayout(QVBoxLayout()) self.grpPandocGeneral = self.collapsibleGroupBox(self.tr("General"), w) - - if majorVersion == "1": - # pandoc v1 only - self.addSettingsWidget("smart", self.grpPandocGeneral) - self.addSettingsWidget("normalize", self.grpPandocGeneral) - else: - # pandoc v2 - self.settingsList.pop("smart", None) - self.settingsList.pop("normalize", None) - self.addSettingsWidget("base-header", self.grpPandocGeneral) - self.addSettingsWidget("standalone", self.grpPandocGeneral) - self.addSettingsWidget("disable-YAML", self.grpPandocGeneral) - + self.grpPandocSpecific = self.collapsibleGroupBox(self.tr("Custom settings for {}").format(self.format), w) self.grpPandocTOC = self.collapsibleGroupBox(self.tr("Table of Content"), w) - self.addSettingsWidget("TOC", self.grpPandocTOC) - self.addSettingsWidget("TOC-depth", self.grpPandocTOC) - - self.grpPandocSpecific = self.collapsibleGroupBox(self.tr("Custom settings for {}").format(self.format), w) - - self.addSettingsWidget("ref-link", self.grpPandocSpecific) - self.addSettingsWidget("atx", self.grpPandocSpecific) - self.addSettingsWidget("self-contained", self.grpPandocSpecific) - self.addSettingsWidget("q-tags", self.grpPandocSpecific) - if majorVersion == "1": - # pandoc v1 only - self.addSettingsWidget("latex-engine", self.grpPandocSpecific) - self.settingsList.pop("pdf-engine", None) - else: - # pandoc v2 - self.settingsList.pop("latex-engine", None) - self.addSettingsWidget("pdf-engine", self.grpPandocSpecific) - self.addSettingsWidget("epub3", self.grpPandocSpecific) - - # PDF settings - self.settingsList.update(self.pdfSettings) - for i in self.pdfSettings: - self.addSettingsWidget(i, self.grpPandocSpecific) + for key, setting in self.settingsList.items(): + if setting.isTOC(): + self.addSettingsWidget(key, self.grpPandocTOC) + elif setting.isSpecific(): + self.addSettingsWidget(key, self.grpPandocSpecific) + else: + self.addSettingsWidget(key, self.grpPandocGeneral) self.toolBox.insertItem(self.toolBox.count() - 1, w, "Pandoc") self.toolBox.layout().setSpacing(0) # Not sure why this is needed, but hey... @@ -310,6 +371,8 @@ class pandocSettings(markdownSettings): extensions += "-yaml_metadata_block" if name == "epub3" and s.widget.isChecked(): toFormat = "epub3" + if name == "hard-line-breaks" and s.widget.isChecked(): + extensions += "+hard_line_breaks" r = ["--from=markdown" + extensions, "--to={}".format(toFormat)] diff --git a/manuskript/exporter/pandoc/outputFormats.py b/manuskript/exporter/pandoc/outputFormats.py index fb59401..c9997b7 100644 --- a/manuskript/exporter/pandoc/outputFormats.py +++ b/manuskript/exporter/pandoc/outputFormats.py @@ -3,34 +3,38 @@ from PyQt5.QtWidgets import qApp from manuskript.exporter.pandoc.abstractOutput import abstractOutput +from manuskript.functions import safeTranslate class ePub(abstractOutput): name = "ePub" - description = qApp.translate("Export", """Books that don't kill trees.""") + description = safeTranslate(qApp, "Export", """Books that don't kill trees.""") icon = "application-epub+zip" exportVarName = "lastPandocePub" toFormat = "epub" exportFilter = "ePub files (*.epub);; Any files (*)" + exportDefaultSuffix = ".epub" class OpenDocument(abstractOutput): name = "OpenDocument" - description = qApp.translate("Export", "OpenDocument format. Used by LibreOffice for example.") + description = safeTranslate(qApp, "Export", "OpenDocument format. Used by LibreOffice for example.") exportVarName = "lastPandocODT" toFormat = "odt" icon = "application-vnd.oasis.opendocument.text" exportFilter = "OpenDocument files (*.odt);; Any files (*)" + exportDefaultSuffix = ".odt" class DocX(abstractOutput): name = "DocX" - description = qApp.translate("Export", "Microsoft Office (.docx) document.") + description = safeTranslate(qApp, "Export", "Microsoft Office (.docx) document.") exportVarName = "lastPandocDocX" toFormat = "docx" icon = "application-vnd.openxmlformats-officedocument.wordprocessingml.document" exportFilter = "DocX files (*.docx);; Any files (*)" + exportDefaultSuffix = ".docx" diff --git a/manuskript/exporter/pandoc/plainText.py b/manuskript/exporter/pandoc/plainText.py index 501789b..cc06a4d 100644 --- a/manuskript/exporter/pandoc/plainText.py +++ b/manuskript/exporter/pandoc/plainText.py @@ -3,43 +3,47 @@ from PyQt5.QtWidgets import qApp from manuskript.exporter.pandoc.abstractPlainText import abstractPlainText +from manuskript.functions import safeTranslate class markdown(abstractPlainText): name = "Markdown" - description = qApp.translate("Export", """Export to markdown, using pandoc. Allows more formatting options + description = safeTranslate(qApp, "Export", """Export to markdown, using pandoc. Allows more formatting options than the basic manuskript exporter.""") icon = "text-x-markdown" exportVarName = "lastPandocMarkdown" toFormat = "markdown" exportFilter = "Markdown files (*.md);; Any files (*)" + exportDefaultSuffix = ".md" class reST(abstractPlainText): name = "reST" - description = qApp.translate("Export", """reStructuredText is a lightweight markup language.""") + description = safeTranslate(qApp, "Export", """reStructuredText is a lightweight markup language.""") exportVarName = "lastPandocreST" toFormat = "rst" icon = "text-plain" exportFilter = "reST files (*.rst);; Any files (*)" + exportDefaultSuffix = ".rst" class latex(abstractPlainText): name = "LaTeX" - description = qApp.translate("Export", """LaTeX is a word processor and document markup language used to create + description = safeTranslate(qApp, "Export", """LaTeX is a word processor and document markup language used to create beautiful documents.""") exportVarName = "lastPandocLatex" toFormat = "latex" icon = "text-x-tex" exportFilter = "Tex files (*.tex);; Any files (*)" + exportDefaultSuffix = ".tex" class OPML(abstractPlainText): name = "OPML" - description = qApp.translate("Export", """The purpose of this format is to provide a way to exchange information + description = safeTranslate(qApp, "Export", """The purpose of this format is to provide a way to exchange information between outliners and Internet services that can be browsed or controlled through an outliner.""") @@ -47,5 +51,5 @@ class OPML(abstractPlainText): toFormat = "opml" icon = "text-x-opml+xml" exportFilter = "OPML files (*.opml);; Any files (*)" - + exportDefaultSuffix = ".opml" diff --git a/manuskript/functions/__init__.py b/manuskript/functions/__init__.py index 9a1ea87..0d15af6 100644 --- a/manuskript/functions/__init__.py +++ b/manuskript/functions/__init__.py @@ -3,25 +3,62 @@ import os import re +import sys +import pathlib from random import * -from PyQt5.QtCore import Qt, QRect, QStandardPaths, QObject, QRegExp, QDir -from PyQt5.QtCore import QUrl, QTimer +from PyQt5.QtCore import Qt, QRect, QStandardPaths, QObject, QProcess, QRegExp +from PyQt5.QtCore import QDir, QUrl, QTimer from PyQt5.QtGui import QBrush, QIcon, QPainter, QColor, QImage, QPixmap from PyQt5.QtGui import QDesktopServices -from PyQt5.QtWidgets import qApp, QTextEdit +from PyQt5.QtWidgets import qApp, QFileDialog from manuskript.enums import Outline +import logging +LOGGER = logging.getLogger(__name__) + # Used to detect multiple connections AUC = Qt.AutoConnection | Qt.UniqueConnection MW = None +def safeTranslate(qApp, group, text): + try: + return qApp.translate(group, text) + except: + return text + def wordCount(text): - t = text.strip().replace(" ", "\n").split("\n") - t = [l for l in t if l] - return len(t) + return len(re.findall(r"\S+", text)) + + +def charCount(text, use_spaces = True): + if use_spaces: + return len(re.findall(r"[\S ]", text)) + else: + return len(re.findall(r"\S", text)) + +validate_ok = lambda *args, **kwargs: True +def uiParse(input, default, converter, validator=validate_ok): + """ + uiParse is a utility function that intends to make it easy to convert + user input to data that falls in the range of expected values the + program is expecting to handle. + + It swallows all exceptions that happen during conversion. + The validator should return True to permit the converted value. + """ + result = default + try: + result = converter(input) + except: + pass # failed to convert + + # Whitelist default value in case default type differs from converter output. + if (result != default) and not validator(result): + result = default + return result def toInt(text): @@ -50,6 +87,7 @@ def toString(text): def drawProgress(painter, rect, progress, radius=0): from manuskript.ui import style as S + progress = toFloat(progress) # handle invalid input (issue #561) painter.setPen(Qt.NoPen) painter.setBrush(QColor(S.base)) # "#dddddd" painter.drawRoundedRect(rect, radius, radius) @@ -57,7 +95,7 @@ def drawProgress(painter, rect, progress, radius=0): painter.setBrush(QBrush(colorFromProgress(progress))) r2 = QRect(rect) - r2.setWidth(r2.width() * min(progress, 1)) + r2.setWidth(int(r2.width() * min(progress, 1))) painter.drawRoundedRect(r2, radius, radius) @@ -140,24 +178,25 @@ def randomColor(mix=None): b = randint(0, 255) if mix: - r = (r + mix.red()) / 2 - g = (g + mix.green()) / 2 - b = (b + mix.blue()) / 2 + r = int((r + mix.red()) / 2) + g = int((g + mix.green()) / 2) + b = int((b + mix.blue()) / 2) return QColor(r, g, b) -def mixColors(col1, col2, f=.5): +def mixColors(col1, col2, f=0.5): fromString = False if type(col1) == str: fromString = True col1 = QColor(col1) if type(col2) == str: col2 = QColor(col2) - f2 = 1-f - r = col1.red() * f + col2.red() * f2 - g = col1.green() * f + col2.green() * f2 - b = col1.blue() * f + col2.blue() * f2 + + f2 = 1.0 - f + r = int(col1.red() * f + col2.red() * f2) + g = int(col1.green() * f + col2.green() * f2) + b = int(col1.blue() * f + col2.blue() * f2) return QColor(r, g, b) if not fromString else QColor(r, g, b).name() @@ -372,7 +411,7 @@ def statusMessage(message, duration=5000, importance=1): MW.statusLabel.adjustSize() g = MW.statusLabel.geometry() # g.moveCenter(MW.mapFromGlobal(MW.geometry().center())) - s = MW.layout().spacing() / 2 + s = int(MW.layout().spacing() / 2) g.setLeft(s) g.moveBottom(MW.mapFromGlobal(MW.geometry().bottomLeft()).y() - s) MW.statusLabel.setGeometry(g) @@ -385,6 +424,28 @@ def openURL(url): """ QDesktopServices.openUrl(QUrl(url)) +def getSaveFileNameWithSuffix(parent, caption, directory, filter, options=None, selectedFilter=None, defaultSuffix=None): + """ + A reimplemented version of QFileDialog.getSaveFileName() because we would like to make use + of the QFileDialog.defaultSuffix property that getSaveFileName() does not let us adjust. + + Note: knowing the selected filter is not an invitation to change the chosen filename later. + """ + dialog = QFileDialog(parent=parent, caption=caption, directory=directory, filter=filter) + if options: + dialog.setOptions(options) + if defaultSuffix: + dialog.setDefaultSuffix(defaultSuffix) + dialog.setFileMode(QFileDialog.AnyFile) + if hasattr(dialog, 'setSupportedSchemes'): # Pre-Qt5.6 lacks this. + dialog.setSupportedSchemes(("file",)) + dialog.setAcceptMode(QFileDialog.AcceptSave) + if selectedFilter: + dialog.selectNameFilter(selectedFilter) + if (dialog.exec() == QFileDialog.Accepted): + return dialog.selectedFiles()[0], dialog.selectedNameFilter() + return None, None + def inspect(): """ Debugging tool. Call it to see a stack of calls up to that point. @@ -397,3 +458,127 @@ def inspect(): s.lineno, s.function)) print(" " + "".join(s.code_context)) + + +def search(searchRegex, text): + """ + Search all occurrences of a regex in a text. + + :param searchRegex: a regex object with the search to perform + :param text: text to search on + :return: list of tuples (startPos, endPos) + """ + if text is not None: + return [(m.start(), m.end(), getSearchResultContext(text, m.start(), m.end())) for m in searchRegex.finditer(text)] + else: + return [] + +def getSearchResultContext(text, startPos, endPos): + matchSize = endPos - startPos + maxContextSize = max(matchSize, 600) + extraContextSize = int((maxContextSize - matchSize) / 2) + separator = "[...]" + + context = "" + + i = startPos - 1 + while i > 0 and (startPos - i) < extraContextSize and text[i] != '\n': + i -= 1 + contextStartPos = i + if i > 0: + context += separator + " " + context += text[contextStartPos:startPos].replace('\n', '') + + context += '' + text[startPos:endPos].replace('\n', '') + '' + + i = endPos + while i < len(text) and (i - endPos) < extraContextSize and text[i] != '\n': + i += 1 + contextEndPos = i + + context += text[endPos:contextEndPos].replace('\n', '') + if i < len(text): + context += " " + separator + + return context + + +# Based on answer by jfs at: +# https://stackoverflow.com/questions/3718657/how-to-properly-determine-current-script-directory +def getManuskriptPath(follow_symlinks=True): + """Used to obtain the path Manuskript is located at.""" + if getattr(sys, 'frozen', False): # py2exe, PyInstaller, cx_Freeze + path = os.path.abspath(sys.executable) + else: + import inspect + path = inspect.getabsfile(getManuskriptPath) + "/../.." + if follow_symlinks: + path = os.path.realpath(path) + return os.path.dirname(path) + +# Based on answer by kagronik at: +# https://stackoverflow.com/questions/14989858/get-the-current-git-hash-in-a-python-script +def getGitRevision(base_path): + """Get git revision without relying on external processes or libraries.""" + git_dir = pathlib.Path(base_path) / '.git' + if not git_dir.exists(): + return None + + with (git_dir / 'HEAD').open('r') as head: + ref = head.readline().split(' ')[-1].strip() + + with (git_dir / ref).open('r') as git_hash: + return git_hash.readline().strip() + +def getGitRevisionAsString(base_path, short=False): + """Catches errors and presents a nice string.""" + try: + rev = getGitRevision(base_path) + if rev is not None: + if short: + rev = rev[:7] + return "#" + rev + else: + return "" # not a git repository + except Exception as e: + LOGGER.warning("Failed to obtain Git revision: %s", e) + return "#ERROR" + +def showInFolder(path, open_file_as_fallback=False): + ''' + Show a file or folder in explorer/finder, highlighting it where possible. + Source: https://stackoverflow.com/a/46019091/3388962 + ''' + path = os.path.abspath(path) + dirPath = path if os.path.isdir(path) else os.path.dirname(path) + if sys.platform == 'win32': + args = [] + args.append('/select,') + args.append(QDir.toNativeSeparators(path)) + if QProcess.startDetached('explorer', args): + return True + elif sys.platform == 'darwin': + args = [] + args.append('-e') + args.append('tell application "Finder"') + args.append('-e') + args.append('activate') + args.append('-e') + args.append('select POSIX file "%s"' % path) + args.append('-e') + args.append('end tell') + args.append('-e') + args.append('return') + if not QProcess.execute('/usr/bin/osascript', args): + return True + #if not QtCore.QProcess.execute('/usr/bin/open', [dirPath]): + # return + # TODO: Linux is not implemented. It has many file managers (nautilus, xdg-open, etc.) + # each of which needs special ways to highlight a file in a file manager window. + + # Fallback. + return QDesktopServices.openUrl(QUrl(path if open_file_as_fallback else dirPath)) + + +# Spellchecker loads writablePath from this file, so we need to load it after they get defined +from manuskript.functions.spellchecker import Spellchecker diff --git a/manuskript/functions/spellchecker.py b/manuskript/functions/spellchecker.py new file mode 100644 index 0000000..b4ae0d2 --- /dev/null +++ b/manuskript/functions/spellchecker.py @@ -0,0 +1,677 @@ +#!/usr/bin/env python +# --!-- coding: utf8 --!-- + +import os, gzip, json, glob, re +from PyQt5.QtCore import QLocale +from collections import OrderedDict +from manuskript.functions import writablePath + +try: + import enchant +except ImportError: + enchant = None + +try: + import spellchecker as pyspellchecker +except ImportError: + pyspellchecker = None + +SYMSPELLPY_MIN_VERSION = "6.3.8" +try: + import symspellpy + import distutils.version + + if distutils.version.LooseVersion(symspellpy.__version__) < SYMSPELLPY_MIN_VERSION: + symspellpy = None + +except ImportError: + symspellpy = None + + +use_language_check = False + +try: + try: + import language_tool_python as languagetool + except: + import language_check as languagetool + use_language_check = True +except: + languagetool = None + +class Spellchecker: + dictionaries = {} + # In order of priority + implementations = [] + + def __init__(self): + pass + + @staticmethod + def registerImplementation(impl): + Spellchecker.implementations.append(impl) + + @staticmethod + def isInstalled(): + for impl in Spellchecker.implementations: + if impl.isInstalled(): + return True + return False + + @staticmethod + def supportedLibraries(): + libs = OrderedDict() + for impl in Spellchecker.implementations: + libs[impl.getLibraryName()] = impl.getLibraryRequirement() + return libs + + @staticmethod + def availableLibraries(): + ret = [] + for impl in Spellchecker.implementations: + if impl.isInstalled(): + ret.append(impl.getLibraryName()) + return ret + + @staticmethod + def availableDictionaries(): + dictionaries = OrderedDict() + for impl in Spellchecker.implementations: + if impl.isInstalled(): + dictionaries[impl.getLibraryName()] = impl.availableDictionaries() + return dictionaries + + @staticmethod + def normalizeDictName(lib, dictionary): + return "{}:{}".format(lib, dictionary) + + @staticmethod + def getDefaultDictionary(): + for impl in Spellchecker.implementations: + default = impl.getDefaultDictionary() + if default: + return Spellchecker.normalizeDictName(impl.getLibraryName(), default) + return None + + @staticmethod + def getLibraryURL(lib=None): + urls = {} + for impl in Spellchecker.implementations: + urls[impl.getLibraryName()] = impl.getLibraryURL() + if lib: + return urls.get(lib, None) + return urls + + @staticmethod + def getDictionary(dictionary): + if not dictionary: + dictionary = Spellchecker.getDefaultDictionary() + if not dictionary: + return None + + values = dictionary.split(":", 1) + if len(values) == 1: + (lib, name) = (Spellchecker.implementations[0].getLibraryName(), dictionary) + dictionary = Spellchecker.normalizeDictName(lib, name) + else: + (lib, name) = values + try: + d = Spellchecker.dictionaries.get(dictionary, None) + if d == None: + for impl in Spellchecker.implementations: + if impl.isInstalled() and lib == impl.getLibraryName(): + d = impl(name) + Spellchecker.dictionaries[dictionary] = d + break + return d + except Exception as e: + pass + return None + +class BasicMatch: + def __init__(self, startIndex, endIndex): + self.start = startIndex + self.end = endIndex + self.locqualityissuetype = 'misspelling' + self.replacements = [] + self.msg = '' + + def getWord(self, text): + return text[self.start:self.end] + +class BasicDictionary: + def __init__(self, name): + self._lang = name + if not self._lang: + self._lang = self.getDefaultDictionary() + + self._customDict = set() + customPath = self.getCustomDictionaryPath() + try: + with gzip.open(customPath, "rt", encoding='utf-8') as f: + self._customDict = set(json.loads(f.read())) + for word in self._customDict: + self._dict.create_dictionary_entry(word, self.CUSTOM_COUNT) + except: + # If error loading the file, overwrite with empty dictionary + self._saveCustomDict() + + @property + def name(self): + return self._lang + + @staticmethod + def getLibraryName(): + raise NotImplemented + + @staticmethod + def getLibraryRequirement(): + return None + + @staticmethod + def getLibraryURL(): + raise NotImplemented + + @staticmethod + def isInstalled(): + raise NotImplemented + + @staticmethod + def getDefaultDictionary(): + raise NotImplemented + + @staticmethod + def availableDictionaries(): + raise NotImplemented + + def checkText(self, text): + # Based on http://john.nachtimwald.com/2009/08/22/qplaintextedit-with-in-line-spell-check/ + WORDS = r'(?iu)((?:[^_\W]|\')+)[^A-Za-z0-9\']' + # (?iu) means case insensitive and Unicode + # ((?:[^_\W]|\')+) means words exclude underscores but include apostrophes + # [^A-Za-z0-9\'] used with above hack to prevent spellcheck while typing word + # + # See also https://stackoverflow.com/questions/2062169/regex-w-in-utf-8 + + matches = [] + + for word_object in re.finditer(WORDS, text): + word = word_object.group(1) + + if (self.isMisspelled(word) and not self.isCustomWord(word)): + matches.append(BasicMatch( + word_object.start(1), word_object.end(1) + )) + + return matches + + def isMisspelled(self, word): + raise NotImplemented + + def getSuggestions(self, word): + raise NotImplemented + + def findSuggestions(self, text, start, end): + if start < end: + word = text[start:end] + + if (self.isMisspelled(word) and not self.isCustomWord(word)): + match = BasicMatch(start, end) + match.replacements = self.getSuggestions(word) + + return [ match ] + + return [] + + def isCustomWord(self, word): + return word.lower() in self._customDict + + def addWord(self, word): + word = word.lower() + if not word in self._customDict: + self._customDict.add(word) + self._saveCustomDict() + + def removeWord(self, word): + word = word.lower() + if word in self._customDict: + self._customDict.remove(word) + self._saveCustomDict() + + @classmethod + def getResourcesPath(cls): + path = os.path.join(writablePath(), "resources", "dictionaries", cls.getLibraryName()) + if not os.path.exists(path): + os.makedirs(path) + return path + + def getCustomDictionaryPath(self): + return os.path.join(self.getResourcesPath(), "{}.json.gz".format(self._lang)) + + def _saveCustomDict(self): + customPath = self.getCustomDictionaryPath() + with gzip.open(customPath, "wt") as f: + f.write(json.dumps(list(self._customDict))) + + +class EnchantDictionary(BasicDictionary): + + def __init__(self, name): + self._lang = name + if not (self._lang and enchant.dict_exists(self._lang)): + self._lang = self.getDefaultDictionary() + + self._dict = enchant.DictWithPWL(self._lang, self.getCustomDictionaryPath()) + + @staticmethod + def getLibraryName(): + return "PyEnchant" + + @staticmethod + def getLibraryURL(): + return "https://pypi.org/project/pyenchant/" + + @staticmethod + def isInstalled(): + return enchant != None + + @staticmethod + def availableDictionaries(): + if EnchantDictionary.isInstalled(): + return list(map(lambda i: str(i[0]), enchant.list_dicts())) + return [] + + @staticmethod + def getDefaultDictionary(): + if not EnchantDictionary.isInstalled(): + return None + + default_locale = enchant.get_default_language() + if default_locale and not enchant.dict_exists(default_locale): + default_locale = None + + if default_locale == None: + default_locale = QLocale.system().name() + if default_locale == None: + default_locale = self.availableDictionaries()[0] + + return default_locale + + def isMisspelled(self, word): + return not self._dict.check(word) + + def getSuggestions(self, word): + return self._dict.suggest(word) + + def isCustomWord(self, word): + return self._dict.is_added(word) + + def addWord(self, word): + self._dict.add(word) + + def removeWord(self, word): + self._dict.remove(word) + + def getCustomDictionaryPath(self): + return os.path.join(self.getResourcesPath(), "{}.txt".format(self.name)) + +class PySpellcheckerDictionary(BasicDictionary): + + def __init__(self, name): + BasicDictionary.__init__(self, name) + + self._dict = pyspellchecker.SpellChecker(self.name) + self._dict.word_frequency.load_words(self._customDict) + + @staticmethod + def getLibraryName(): + return "pyspellchecker" + + @staticmethod + def getLibraryURL(): + return "https://pyspellchecker.readthedocs.io/en/latest/" + + @staticmethod + def isInstalled(): + return pyspellchecker != None + + @staticmethod + def availableDictionaries(): + if PySpellcheckerDictionary.isInstalled(): + dictionaries = [] + files = glob.glob(os.path.join(pyspellchecker.__path__[0], "resources", "*.json.gz")) + for file in files: + dictionaries.append(os.path.basename(file)[:-8]) + return dictionaries + return [] + + @staticmethod + def getDefaultDictionary(): + if not PySpellcheckerDictionary.isInstalled(): + return None + + default_locale = QLocale.system().name() + if default_locale: + default_locale = default_locale[0:2] + if default_locale == None: + default_locale = "en" + + return default_locale + + def isMisspelled(self, word): + return len(self._dict.unknown([word])) > 0 + + def getSuggestions(self, word): + candidates = self._dict.candidates(word) + if word in candidates: + candidates.remove(word) + return candidates + + def addWord(self, word): + BasicDictionary.addWord(self, word) + self._dict.word_frequency.add(word.lower()) + + def removeWord(self, word): + BasicDictionary.removeWord(self, word) + self._dict.word_frequency.remove(word.lower()) + +class SymSpellDictionary(BasicDictionary): + CUSTOM_COUNT = 1 + DISTANCE = 2 + + def __init__(self, name): + BasicDictionary.__init__(self, name) + + self._dict = symspellpy.SymSpell(self.DISTANCE) + + cachePath = self.getCachedDictionaryPath() + try: + if not self._dict.load_pickle(cachePath, False): + raise Exception("Can't load cached dictionary. " + + "File might be corrupted or incompatible with installed symspellpy version") + except: + if pyspellchecker: + path = os.path.join(pyspellchecker.__path__[0], "resources", "{}.json.gz".format(self.name)) + if os.path.exists(path): + with gzip.open(path, "rt", encoding='utf-8') as f: + data = json.loads(f.read()) + for key in data: + self._dict.create_dictionary_entry(key, data[key]) + self._dict.save_pickle(cachePath, False) + for word in self._customDict: + self._dict.create_dictionary_entry(word, self.CUSTOM_COUNT) + + def getCachedDictionaryPath(self): + return os.path.join(self.getResourcesPath(), "{}.sym".format(self.name)) + + @staticmethod + def getLibraryName(): + return "symspellpy" + + @staticmethod + def getLibraryRequirement(): + return ">= " + SYMSPELLPY_MIN_VERSION + + @staticmethod + def getLibraryURL(): + return "https://github.com/mammothb/symspellpy" + + @staticmethod + def isInstalled(): + return symspellpy != None + + @classmethod + def availableDictionaries(cls): + if SymSpellDictionary.isInstalled(): + files = glob.glob(os.path.join(cls.getResourcesPath(), "*.sym")) + dictionaries = [] + for file in files: + dictionaries.append(os.path.basename(file)[:-4]) + for sp_dict in PySpellcheckerDictionary.availableDictionaries(): + if not sp_dict in dictionaries: + dictionaries.append(sp_dict) + return dictionaries + return [] + + @staticmethod + def getDefaultDictionary(): + if not SymSpellDictionary.isInstalled(): + return None + + return PySpellcheckerDictionary.getDefaultDictionary() + + def isMisspelled(self, word): + suggestions = self._dict.lookup(word.lower(), symspellpy.Verbosity.TOP) + if len(suggestions) > 0 and suggestions[0].distance == 0: + return False + # Try the word as is, since a dictionary might have uppercase letter as part + # of it's spelling ("I'm" or "January" for example) + suggestions = self._dict.lookup(word, symspellpy.Verbosity.TOP) + if len(suggestions) > 0 and suggestions[0].distance == 0: + return False + return True + + def getSuggestions(self, word): + upper = word.isupper() + upper1 = word[0].isupper() + suggestions = self._dict.lookup_compound(word, 2) + suggestions.extend(self._dict.lookup(word, symspellpy.Verbosity.CLOSEST)) + candidates = [] + for sug in suggestions: + if upper: + term = sug.term.upper() + elif upper1: + term = sug.term[0].upper() + sug.term[1:] + else: + term = sug.term + if sug.distance > 0 and not term in candidates: + candidates.append(term) + return candidates + + def addWord(self, word): + BasicDictionary.addWord(self, word) + self._dict.create_dictionary_entry(word.lower(), self.CUSTOM_COUNT) + + def removeWord(self, word): + BasicDictionary.removeWord(self, word) + # Since 6.3.8 + self._dict.delete_dictionary_entry(word) + +def get_languagetool_match_errorLength(match): + if use_language_check: + return match.errorlength + else: + return match.errorLength + +def get_languagetool_match_ruleIssueType(match): + if use_language_check: + return match.locqualityissuetype + else: + return match.ruleIssueType + +def get_languagetool_match_message(match): + if use_language_check: + return match.msg + else: + return match.message + +class LanguageToolCache: + + def __init__(self, tool, text): + self._length = len(text) + self._matches = self._buildMatches(tool, text) + + def getMatches(self): + return self._matches + + def _buildMatches(self, tool, text): + matches = [] + + for match in tool.check(text): + start = match.offset + end = start + get_languagetool_match_errorLength(match) + + basic_match = BasicMatch(start, end) + basic_match.locqualityissuetype = get_languagetool_match_ruleIssueType(match) + basic_match.replacements = match.replacements + basic_match.msg = get_languagetool_match_message(match) + + matches.append(basic_match) + + return matches + + def update(self, tool, text): + if len(text) != self._length: + self._matches = self._buildMatches(tool, text) + +def get_languagetool_languages(tool): + if use_language_check: + return languagetool.get_languages() + else: + return tool._get_languages() + +def get_languagetool_locale_language(): + if use_language_check: + return languagetool.get_locale_language() + else: + return languagetool.utils.get_locale_language() + +class LanguageToolDictionary(BasicDictionary): + + _tool = None + + def __init__(self, name): + BasicDictionary.__init__(self, name) + + if not (self._lang and self._lang in get_languagetool_languages(self.getTool())): + self._lang = self.getDefaultDictionary() + + self.tool = languagetool.LanguageTool(self._lang) + self._cache = {} + + @staticmethod + def getTool(): + if LanguageToolDictionary._tool == None: + try: + LanguageToolDictionary._tool = languagetool.LanguageTool() + except: + return None + + return LanguageToolDictionary._tool + + @staticmethod + def getLibraryName(): + return "LanguageTool" + + @staticmethod + def getLibraryURL(): + if use_language_check: + return "https://pypi.org/project/language-check/" + else: + return "https://pypi.org/project/language-tool-python/" + + @staticmethod + def isInstalled(): + if (languagetool != None) and (LanguageToolDictionary.getTool() != None): + + # This check, if Java is installed, is necessary to + # make sure LanguageTool can be run without problems. + # + return (os.system('java -version') == 0) + + return False + + @staticmethod + def availableDictionaries(): + if LanguageToolDictionary.isInstalled(): + tool = LanguageToolDictionary.getTool() + languages = list(get_languagetool_languages(tool)) + languages.sort() + return languages + + return [] + + @staticmethod + def getDefaultDictionary(): + if not LanguageToolDictionary.isInstalled(): + return None + + default_locale = get_languagetool_locale_language() + tool = LanguageToolDictionary.getTool() + + if default_locale and not default_locale in get_languagetool_languages(tool): + default_locale = None + + if default_locale == None: + default_locale = QLocale.system().name() + if default_locale == None: + default_locale = self.availableDictionaries()[0] + + return default_locale + + def checkText(self, text): + matches = [] + + if len(text) == 0: + return matches + + textId = hash(text) + cacheEntry = None + + if not textId in self._cache: + cacheEntry = LanguageToolCache(self.tool, text) + + self._cache[textId] = cacheEntry + else: + cacheEntry = self._cache[textId] + cacheEntry.update(self.tool, text) + + for match in cacheEntry.getMatches(): + word = match.getWord(text) + + if not (match.locqualityissuetype == 'misspelling' and self.isCustomWord(word)): + matches.append(match) + + return matches + + def isMisspelled(self, word): + if self.isCustomWord(word): + return False + + for match in self.checkText(word): + if match.locqualityissuetype == 'misspelling': + return True + + return False + + def getSuggestions(self, word): + suggestions = [] + + for match in self.checkText(word): + suggestions += match.replacements + + return suggestions + + def findSuggestions(self, text, start, end): + matches = [] + checked = self.checkText(text) + + if start == end: + # Check for containing area: + for match in checked: + if (start >= match.start and start <= match.end): + matches.append(match) + else: + # Check for overlapping area: + for match in checked: + if (match.end > start and match.start < end): + matches.append(match) + + return matches + + +# Register the implementations in order of priority +Spellchecker.registerImplementation(EnchantDictionary) +Spellchecker.registerImplementation(SymSpellDictionary) +Spellchecker.registerImplementation(PySpellcheckerDictionary) +Spellchecker.registerImplementation(LanguageToolDictionary) diff --git a/manuskript/importer/folderImporter.py b/manuskript/importer/folderImporter.py index 506a9b3..b6aa6c9 100644 --- a/manuskript/importer/folderImporter.py +++ b/manuskript/importer/folderImporter.py @@ -5,6 +5,8 @@ import os from manuskript.importer.abstractImporter import abstractImporter from manuskript.models import outlineItem from manuskript.enums import Outline +from manuskript.functions import safeTranslate + from PyQt5.QtWidgets import qApp @@ -44,7 +46,7 @@ class folderImporter(abstractImporter): fName, fExt = os.path.splitext(f) if fExt.lower() in ext: try: - with open(os.path.join(dirpath, f), "r") as fr: + with open(os.path.join(dirpath, f), "r", encoding="utf-8") as fr: content = fr.read() child = outlineItem(title=fName, _type="md", parent=item) child._data[Outline.text] = content @@ -94,27 +96,27 @@ class folderImporter(abstractImporter): # Add group group = self.addGroup(widget.toolBox.widget(0), - qApp.translate("Import", "Folder import")) + safeTranslate(qApp, "Import", "Folder import")) #group = cls.addPage(widget, "Folder import") self.addSetting("info", "label", - qApp.translate("Import", """Info: Imports a whole + safeTranslate(qApp, "Import", """
Info: Imports a whole directory structure. Folders are added as folders, and plaintext documents within (you chose which ones by extension) are added as scene.
Only text files are supported (not images, binary or others).
""")) self.addSetting("ext", "text", - qApp.translate("Import", "Include only those extensions:"), + safeTranslate(qApp, "Import", "Include only those extensions:"), default="*.txt, *.md", - tooltip=qApp.translate("Import", "Comma separated values")), + tooltip=safeTranslate(qApp, "Import", "Comma separated values")), self.addSetting("sortItems", "checkbox", - qApp.translate("Import", "Sort items by name"), + safeTranslate(qApp, "Import", "Sort items by name"), default=True), self.addSetting("separateFolderFiles", "checkbox", - qApp.translate("Import", "Import folder then files"), + safeTranslate(qApp, "Import", "Import folder then files"), default=True), self.addSettingsTo(group) diff --git a/manuskript/importer/markdownImporter.py b/manuskript/importer/markdownImporter.py index 107931e..b0d0d7e 100644 --- a/manuskript/importer/markdownImporter.py +++ b/manuskript/importer/markdownImporter.py @@ -4,6 +4,8 @@ from manuskript.importer.abstractImporter import abstractImporter from manuskript.models import outlineItem from manuskript.enums import Outline +from manuskript.functions import safeTranslate + from PyQt5.QtWidgets import qApp import re, os @@ -63,7 +65,7 @@ class markdownImporter(abstractImporter): if not fromString: # Read file - with open(filePath, "r") as f: + with open(filePath, "r", encoding="utf-8") as f: txt = f.read() else: txt = fromString @@ -173,11 +175,11 @@ class markdownImporter(abstractImporter): # Add group group = self.addGroup(widget.toolBox.widget(0), - qApp.translate("Import", "Markdown import")) + safeTranslate(qApp, "Import", "Markdown import")) #group = cls.addPage(widget, "Folder import") self.addSetting("info", "label", - qApp.translate("Import", """Info: A very simple + safeTranslate(qApp, "Import", """Info: A very simple parser that will go through a markdown document and create items for each titles.
""")) diff --git a/manuskript/importer/mindMapImporter.py b/manuskript/importer/mindMapImporter.py index 31155c9..03a7cc4 100644 --- a/manuskript/importer/mindMapImporter.py +++ b/manuskript/importer/mindMapImporter.py @@ -5,10 +5,11 @@ from PyQt5.QtWidgets import qApp, QMessageBox from manuskript.models import outlineItem from manuskript.enums import Outline from lxml import etree as ET -from manuskript.functions import mainWindow +from manuskript.functions import mainWindow, safeTranslate from manuskript.importer.abstractImporter import abstractImporter from manuskript.converters import HTML2MD, HTML2PlainText + class mindMapImporter(abstractImporter): name = "Mind Map" @@ -47,15 +48,15 @@ class mindMapImporter(abstractImporter): node = root.find("node") items = [] - if node is not None: + if node != None: items.extend(self.parseItems(node, parentItem)) ret = True if not ret: QMessageBox.critical( settingsWidget, - qApp.translate("Import", "Mind Map Import"), - qApp.translate("Import", "This does not appear to be a valid Mind Map file.")) + safeTranslate(qApp, "Import", "Mind Map Import"), + safeTranslate(qApp, "Import", "This does not appear to be a valid Mind Map file.")) return None @@ -68,10 +69,10 @@ class mindMapImporter(abstractImporter): # Add group group = self.addGroup(widget.toolBox.widget(0), - qApp.translate("Import", "Mind Map import")) + safeTranslate(qApp, "Import", "Mind Map import")) self.addSetting("importTipAs", "combo", - qApp.translate("Import", "Import tip as:"), + safeTranslate(qApp, "Import", "Import tip as:"), vals="Text|Folder", ) @@ -86,7 +87,7 @@ class mindMapImporter(abstractImporter): # Title title = underElement.get('TEXT', "").replace("\n", " ") if not title: - title = qApp.translate("Import", "Untitled") + title = safeTranslate(qApp, "Import", "Untitled") item = outlineItem(parent=parentItem, title=title) items.append(item) @@ -97,7 +98,7 @@ class mindMapImporter(abstractImporter): # Rich text content content = "" content = underElement.find("richcontent") - if content is not None: + if content != None: # In Freemind, can be note or node # Note: it's a note # Node: it's the title of the node, in rich text @@ -130,7 +131,7 @@ class mindMapImporter(abstractImporter): children = underElement.findall('node') # Process children - if children is not None and len(children) > 0: + if children != None and len(children) > 0: for c in children: items.extend(self.parseItems(c, item)) diff --git a/manuskript/importer/opmlImporter.py b/manuskript/importer/opmlImporter.py index e434cd5..4cc537d 100644 --- a/manuskript/importer/opmlImporter.py +++ b/manuskript/importer/opmlImporter.py @@ -5,9 +5,10 @@ from PyQt5.QtWidgets import qApp, QMessageBox from manuskript.models import outlineItem from manuskript.enums import Outline from lxml import etree as ET -from manuskript.functions import mainWindow +from manuskript.functions import mainWindow, safeTranslate from manuskript.importer.abstractImporter import abstractImporter + class opmlImporter(abstractImporter): name = "OPML" @@ -34,8 +35,8 @@ class opmlImporter(abstractImporter): opmlContent = opmlFile.read() except: QMessageBox.critical(settingsWidget, - qApp.translate("Import", "OPML Import"), - qApp.translate("Import", "File open failed.")) + safeTranslate(qApp, "Import", "OPML Import"), + safeTranslate(qApp, "Import", "File open failed.")) return None elif fromString == "": @@ -52,10 +53,10 @@ class opmlImporter(abstractImporter): bodyNode = opmlNode.find("body") items = [] - if bodyNode is not None: + if bodyNode != None: outlineEls = bodyNode.findall("outline") - if outlineEls is not None: + if outlineEls != None: for element in outlineEls: items.extend(cls.parseItems(element, parentItem)) ret = True @@ -63,8 +64,8 @@ class opmlImporter(abstractImporter): if not ret: QMessageBox.critical( settingsWidget, - qApp.translate("Import", "OPML Import"), - qApp.translate("Import", "This does not appear to be a valid OPML file.")) + safeTranslate(qApp, "Import", "OPML Import"), + safeTranslate(qApp, "Import", "This does not appear to be a valid OPML file.")) return None @@ -74,19 +75,20 @@ class opmlImporter(abstractImporter): def parseItems(cls, underElement, parentItem=None): items = [] title = underElement.get('text') - if title is not None: - + if title != None: card = outlineItem(parent=parentItem, title=title) items.append(card) body = "" note = underElement.get('_note') - if note is not None and not cls.isWhitespaceOnly(note): + + if note != None and not cls.isWhitespaceOnly(note): #body = cls.restoreNewLines(note) body = note children = underElement.findall('outline') - if children is not None and len(children) > 0: + + if children != None and len(children) > 0: for el in children: items.extend(cls.parseItems(el, card)) else: @@ -121,4 +123,4 @@ class opmlImporter(abstractImporter): s = cls.restoreNewLines(inString) s = ''.join(s.split()) - return len(s) is 0 + return len(s) == 0 diff --git a/manuskript/importer/pandocImporters.py b/manuskript/importer/pandocImporters.py index 023a4b0..e2f9267 100644 --- a/manuskript/importer/pandocImporters.py +++ b/manuskript/importer/pandocImporters.py @@ -5,6 +5,8 @@ from manuskript.importer.abstractImporter import abstractImporter from manuskript.exporter.pandoc import pandocExporter from manuskript.importer.opmlImporter import opmlImporter from manuskript.importer.markdownImporter import markdownImporter +from manuskript.functions import safeTranslate + from PyQt5.QtWidgets import qApp @@ -38,6 +40,9 @@ class pandocImporter(abstractImporter): r = pandocExporter().run(args) + if r == None: + return None + if formatTo == "opml": return self.opmlImporter.startImport("", parentItem, settingsWidget, fromString=r) @@ -52,10 +57,10 @@ class pandocImporter(abstractImporter): # Add group group = self.addGroup(widget.toolBox.widget(0), - qApp.translate("Import", "Pandoc import")) + safeTranslate(qApp, "Import", "Pandoc import")) self.addSetting("info", "label", - qApp.translate("Import", """Info: Manuskript can + safeTranslate(qApp, "Import", """Info: Manuskript can import from markdown or OPML. Pandoc will convert your document to either (see option below), and then it will be imported in manuskript. One or the other @@ -63,14 +68,14 @@ class pandocImporter(abstractImporter):
""")) self.addSetting("formatTo", "combo", - qApp.translate("Import", "Import using:"), + safeTranslate(qApp, "Import", "Import using:"), vals="markdown|OPML") self.addSetting("wrap", "combo", - qApp.translate("Import", "Wrap lines:"), + safeTranslate(qApp, "Import", "Wrap lines:"), vals="auto|none|preserve", default="none", - tooltip=qApp.translate("Import", """Should pandoc create + tooltip=safeTranslate(qApp, "Import", """
Should pandoc create cosmetic / non-semantic line-breaks?
auto: wraps at 72 characters.
none: no line wrap.
diff --git a/manuskript/loadSave.py b/manuskript/loadSave.py index e603a85..1adfe4c 100644 --- a/manuskript/loadSave.py +++ b/manuskript/loadSave.py @@ -9,6 +9,8 @@ import zipfile import manuskript.load_save.version_0 as v0 import manuskript.load_save.version_1 as v1 +import logging +LOGGER = logging.getLogger(__name__) def saveProject(version=None): @@ -54,13 +56,13 @@ def loadProject(project): # Not a zip else: - with open(project, "r") as f: + with open(project, "r", encoding="utf-8") as f: version = int(f.read()) - print("Loading:", project) - print("Detected file format version: {}. Zip: {}.".format(version, isZip)) + LOGGER.info("Loading: %s", project) + LOGGER.info("Detected file format version: {}. Zip: {}.".format(version, isZip)) if version == 0: - v0.loadProject(project) + return v0.loadProject(project) else: - v1.loadProject(project, zip=isZip) + return v1.loadProject(project, zip=isZip) diff --git a/manuskript/load_save/version_0.py b/manuskript/load_save/version_0.py index 2e31150..e6ca5fc 100644 --- a/manuskript/load_save/version_0.py +++ b/manuskript/load_save/version_0.py @@ -16,6 +16,9 @@ from manuskript import settings from manuskript.functions import iconColor, iconFromColorString, mainWindow from manuskript.models.characterModel import Character, CharacterInfo +import logging +LOGGER = logging.getLogger(__name__) + try: import zlib # Used with zipfile for compression @@ -37,7 +40,7 @@ def saveProject(): files.append((saveStandardItemModelXML(mw.mdlFlatData), "flatModel.xml")) - print("ERROR: file format 0 does not save characters !") + LOGGER.error("File format 0 does not save characters!") # files.append((saveStandardItemModelXML(mw.mdlCharacter), # "perso.xml")) files.append((saveStandardItemModelXML(mw.mdlWorld), @@ -91,7 +94,7 @@ def saveStandardItemModelXML(mdl, xml=None): data = ET.SubElement(root, "data") saveItem(data, mdl) - # print(qApp.tr("Saving to {}.").format(xml)) + # LOGGER.info("Saving to {}.".format(xml)) if xml: ET.ElementTree(root).write(xml, encoding="UTF-8", xml_declaration=True, pretty_print=True) else: @@ -165,10 +168,13 @@ def loadProject(project): else: errors.append("outline.xml") - if "settings.pickle" in files: - settings.load(files["settings.pickle"], fromString=True) + if "settings.txt" in files: + settings.load(files["settings.txt"], fromString=True, protocol=0) else: - errors.append("settings.pickle") + errors.append("settings.txt") + + if "settings.pickle" in files: + LOGGER.info("Pickle settings files are no longer supported for security reasons. You can delete it from your data.") return errors @@ -178,7 +184,10 @@ def loadFilesFromZip(zipname): zf = zipfile.ZipFile(zipname) files = {} for f in zf.namelist(): - files[os.path.normpath(f)] = zf.read(f) + # Some archiving programs (e.g. 7-Zip) also store entries for the directories when + # creating an archive. We have no use for these entries; skip them entirely. + if f[-1:] != '/': + files[os.path.normpath(f)] = zf.read(f) return files @@ -186,13 +195,13 @@ def loadStandardItemModelXML(mdl, xml, fromString=False): """Load data to a QStandardItemModel mdl from xml. By default xml is a filename. If fromString=True, xml is a string containing the data.""" - # print(qApp.tr("Loading {}... ").format(xml), end="") + # LOGGER.info("Loading {}...".format(xml)) if not fromString: try: tree = ET.parse(xml) except: - print("Failed.") + LOGGER.error("Failed to load XML for QStandardItemModel (%s).", xml) return else: root = ET.fromstring(xml) @@ -207,7 +216,7 @@ def loadStandardItemModelXML(mdl, xml, fromString=False): for l in root.find("header").find("vertical").findall("label"): vLabels.append(l.attrib["text"]) - # print(root.find("header").find("vertical").text) + # LOGGER.debug(root.find("header").find("vertical").text) # mdl.setVerticalHeaderLabels(vLabels) # mdl.setHorizontalHeaderLabels(hLabels) diff --git a/manuskript/load_save/version_1.py b/manuskript/load_save/version_1.py index 048ae26..b2ad66c 100644 --- a/manuskript/load_save/version_1.py +++ b/manuskript/load_save/version_1.py @@ -15,6 +15,7 @@ from collections import OrderedDict from PyQt5.QtCore import Qt, QModelIndex from PyQt5.QtGui import QColor, QStandardItem +from PyQt5.QtWidgets import QListWidgetItem from manuskript import settings from manuskript.enums import Character, World, Plot, PlotStep, Outline @@ -25,6 +26,10 @@ from lxml import etree as ET from manuskript.load_save.version_0 import loadFilesFromZip from manuskript.models.characterModel import CharacterInfo from manuskript.models import outlineItem +from manuskript.ui.listDialog import ListDialog + +import logging +LOGGER = logging.getLogger(__name__) try: import zlib # Used with zipfile for compression @@ -40,6 +45,7 @@ characterMap = OrderedDict([ (Character.name, "Name"), (Character.ID, "ID"), (Character.importance, "Importance"), + (Character.pov, "POV"), (Character.motivation, "Motivation"), (Character.goal, "Goal"), (Character.conflict, "Conflict"), @@ -47,11 +53,9 @@ characterMap = OrderedDict([ (Character.summarySentence, "Phrase Summary"), (Character.summaryPara, "Paragraph Summary"), (Character.summaryFull, "Full Summary"), - (Character.notes, "Notes"), + (Character.notes, "Notes") ]) -# If true, logs infos while saving and loading. -LOG = False def formatMetaData(name, value, tabLength=10): @@ -91,11 +95,6 @@ def slugify(name): return newName -def log(*args): - if LOG: - print(" ".join(str(a) for a in args)) - - def saveProject(zip=None): """ Saves the project. If zip is False, the project is saved as a multitude of plain-text files for the most parts @@ -106,10 +105,10 @@ def saveProject(zip=None): settings. @return: True if successful, False otherwise. """ - if zip is None: + if zip == None: zip = settings.saveToZip - log("\n\nSaving to:", "zip" if zip else "folder") + LOGGER.info("Saving to: %s", "zip" if zip else "folder") # List of files to be written files = [] @@ -118,7 +117,14 @@ def saveProject(zip=None): # List of files to be moved moves = [] + # MainWindow interaction things. mw = mainWindow() + project = mw.currentProject + + # Sanity check (see PR-583): make sure we actually have a current project. + if project == None: + LOGGER.error("Cannot save project because there is no current project in the UI.") + return False # File format version files.append(("MANUSKRIPT", "1")) @@ -190,7 +196,7 @@ def saveProject(zip=None): # We skip the first row, which is empty and transparent for i in range(1, mdl.rowCount()): color = "" - if mdl.data(mdl.index(i, 0), Qt.DecorationRole) is not None: + if mdl.data(mdl.index(i, 0), Qt.DecorationRole) != None: color = iconColor(mdl.data(mdl.index(i, 0), Qt.DecorationRole)).name(QColor.HexRgb) color = color if color != "#ff000000" else "#00000000" @@ -295,13 +301,11 @@ def saveProject(zip=None): files.append(("settings.txt", settings.save(protocol=0))) - project = mw.currentProject - # We check if the file exist and we have write access. If the file does - # not exists, we check the parent folder, because it might be a new project. + # not exist, we check the parent folder, because it might be a new project. if os.path.exists(project) and not os.access(project, os.W_OK) or \ not os.path.exists(project) and not os.access(os.path.dirname(project), os.W_OK): - print("Error: you don't have write access to save this project there.") + LOGGER.error("You don't have write access to save this project there.") return False #################################################################################################################### @@ -325,8 +329,8 @@ def saveProject(zip=None): # Save to plain text else: - global cache + filesWithPermissionErrors = list() # Project path dir = os.path.dirname(project) @@ -335,7 +339,7 @@ def saveProject(zip=None): folder = os.path.splitext(os.path.basename(project))[0] # Debug - log("\nSaving to folder", folder) + LOGGER.debug("Saving to folder %s", folder) # If cache is empty (meaning we haven't loaded from disk), we wipe folder, just to be sure. if not cache: @@ -352,7 +356,7 @@ def saveProject(zip=None): # Move the old file to the new place try: os.replace(oldPath, newPath) - log("* Renaming/moving {} to {}".format(old, new)) + LOGGER.debug("* Renaming/moving {} to {}".format(old, new)) except FileNotFoundError: # Maybe parent folder has been renamed pass @@ -362,7 +366,7 @@ def saveProject(zip=None): for f in cache: f2 = f.replace(old, new) if f2 != f: - log(" * Updating cache:", f, f2) + LOGGER.debug(" * Updating cache: %s, %s", f, f2) cache2[f2] = cache[f] cache = cache2 @@ -373,21 +377,29 @@ def saveProject(zip=None): # Check if content is in cache, and write if necessary if path not in cache or cache[path] != content: - log("* Writing file {} ({})".format(path, "not in cache" if path not in cache else "different")) + LOGGER.debug("* Writing file {} ({})".format(path, "not in cache" if path not in cache else "different")) # mode = "w" + ("b" if type(content) == bytes else "") if type(content) == bytes: - with open(filename, "wb") as f: - f.write(content) + try: + with open(filename, "wb") as f: + f.write(content) + except PermissionError as e: + LOGGER.error("Cannot open file " + filename + " for writing: " + e.strerror) + filesWithPermissionErrors.append(filename) else: - with open(filename, "w", encoding='utf8') as f: - f.write(content) + try: + with open(filename, "w", encoding='utf8') as f: + f.write(content) + except PermissionError as e: + LOGGER.error("Cannot open file " + filename + " for writing: " + e.strerror) + filesWithPermissionErrors.append(filename) cache[path] = content # Removing phantoms for path in [p for p in cache if p not in [p for p, c in files]]: filename = os.path.join(dir, folder, path) - log("* Removing", path) + LOGGER.debug("* Removing %s", path) if os.path.isdir(filename): shutil.rmtree(filename) @@ -404,15 +416,30 @@ def saveProject(zip=None): newDir = os.path.join(root, dir) try: os.removedirs(newDir) - log("* Removing empty directory:", newDir) + LOGGER.debug("* Removing empty directory: %s", newDir) except: # Directory not empty, we don't remove. pass # Write the project file's content - with open(project, "w", encoding='utf8') as f: - f.write("1") # Format number + try: + with open(project, "w", encoding='utf8') as f: + f.write("1") # Format number + except PermissionError as e: + LOGGER.error("Cannot open file " + project + " for writing: " + e.strerror) + filesWithPermissionErrors.append(project) + if len(filesWithPermissionErrors) > 0: + dlg = ListDialog(mw) + dlg.setModal(True) + dlg.setWindowTitle(dlg.tr("Files not saved")) + dlg.label.setText(dlg.tr("The following files were not saved and appear to be open in another program")) + for f in filesWithPermissionErrors: + QListWidgetItem(f, dlg.listWidget) + dlg.open() + + if project in filesWithPermissionErrors: + return False return True @@ -530,8 +557,8 @@ def exportOutlineItem(root): lp = child._lastPath if lp and spath != lp: moves.append((lp, spath)) - log(child.title(), "has been renamed (", lp, " → ", spath, ")") - log(" → We mark for moving:", lp) + LOGGER.debug("%s has been renamed (%s → %s)", child.title(), lp, spath) + LOGGER.debug(" → We mark for moving: %s", lp) # Updates item last's path child._lastPath = spath @@ -547,7 +574,7 @@ def exportOutlineItem(root): files.append((spath, content)) else: - log("Unknown type") + LOGGER.debug("Unknown type: %s", child.type()) f, m, r = exportOutlineItem(child) files += f @@ -620,12 +647,13 @@ def loadProject(project, zip=None): """ mw = mainWindow() - errors = [] + errors = list() + filesWithPermissionErrors = list() #################################################################################################################### # Read and store everything in a dict - log("\nLoading {} ({})".format(project, "ZIP" if zip else "not zip")) + LOGGER.debug("Loading {} ({})".format(project, "zip" if zip else "folder")) if zip: files = loadFilesFromZip(project) @@ -659,8 +687,14 @@ def loadProject(project, zip=None): with open(os.path.join(dirpath, f), "rb") as fo: files[os.path.join(p, f)] = fo.read() else: - with open(os.path.join(dirpath, f), "r", encoding="utf8") as fo: - files[os.path.join(p, f)] = fo.read() + try: + filename = os.path.join(dirpath, f) + with open(filename, "r", encoding="utf8") as fo: + files[os.path.join(p, f)] = fo.read() + except PermissionError as e: + LOGGER.error("Cannot open file " + filename + ": " + e.strerror) + errors.append(fo) + filesWithPermissionErrors.append(filename) # Saves to cache (only if we loaded from disk and not zip) global cache @@ -689,7 +723,7 @@ def loadProject(project, zip=None): mdl = mw.mdlLabels mdl.appendRow(QStandardItem("")) # Empty = No labels if "labels.txt" in files: - log("\nReading labels:") + LOGGER.debug("Reading labels:") for s in files["labels.txt"].split("\n"): if not s: continue @@ -697,7 +731,7 @@ def loadProject(project, zip=None): m = re.search(r"^(.*?):\s*(.*)$", s) txt = m.group(1) col = m.group(2) - log("* Add status: {} ({})".format(txt, col)) + LOGGER.debug("* Add status: {} ({})".format(txt, col)) icon = iconFromColorString(col) mdl.appendRow(QStandardItem(icon, txt)) @@ -710,11 +744,11 @@ def loadProject(project, zip=None): mdl = mw.mdlStatus mdl.appendRow(QStandardItem("")) # Empty = No status if "status.txt" in files: - log("\nReading Status:") + LOGGER.debug("Reading status:") for s in files["status.txt"].split("\n"): if not s: continue - log("* Add status:", s) + LOGGER.debug("* Add status: %s", s) mdl.appendRow(QStandardItem(s)) else: errors.append("status.txt") @@ -756,7 +790,7 @@ def loadProject(project, zip=None): mdl = mw.mdlPlots if "plots.xml" in files: - log("\nReading plots:") + LOGGER.debug("Reading plots:") # xml = bytearray(files["plots.xml"], "utf-8") root = ET.fromstring(files["plots.xml"]) @@ -765,7 +799,7 @@ def loadProject(project, zip=None): row = getStandardItemRowFromXMLEnum(plot, Plot) # Log - log("* Add plot: ", row[0].text()) + LOGGER.debug("* Add plot: %s", row[0].text()) # Characters if row[Plot.characters].text(): @@ -792,7 +826,7 @@ def loadProject(project, zip=None): mdl = mw.mdlWorld if "world.opml" in files: - log("\nReading World:") + LOGGER.debug("Reading World:") # xml = bytearray(files["plots.xml"], "utf-8") root = ET.fromstring(files["world.opml"]) body = root.find("body") @@ -808,7 +842,7 @@ def loadProject(project, zip=None): # Characters mdl = mw.mdlCharacter - log("\nReading Characters:") + LOGGER.debug("Reading Characters:") for f in [f for f in files if "characters" in f]: md, body = parseMMDFile(files[f]) c = mdl.addCharacter() @@ -834,7 +868,7 @@ def loadProject(project, zip=None): else: c.infos.append(CharacterInfo(c, desc, val)) - log("* Adds {} ({})".format(c.name(), c.ID())) + LOGGER.debug("* Adds {} ({})".format(c.name(), c.ID())) #################################################################################################################### # Texts @@ -842,14 +876,14 @@ def loadProject(project, zip=None): # everything, but the outline folder takes precedence (in cases it's been edited outside of manuskript. mdl = mw.mdlOutline - log("\nReading outline:") + LOGGER.debug("Reading outline:") paths = [f for f in files if "outline" in f] outline = OrderedDict() # We create a structure of imbricated OrderedDict to store the whole tree. for f in paths: split = f.split(os.path.sep)[1:] - # log("* ", split) + # LOGGER.debug("* %s", split) last = "" parent = outline @@ -885,6 +919,15 @@ def loadProject(project, zip=None): # Check IDS mdl.rootItem.checkIDs() + if len(filesWithPermissionErrors) > 0: + dlg = ListDialog(mw) + dlg.setModal(True) + dlg.setWindowTitle(dlg.tr("Files not loaded")) + dlg.label.setText(dlg.tr("The following files were not loaded and appear to be open in another program")) + for f in filesWithPermissionErrors: + QListWidgetItem(f, dlg.listWidget) + dlg.open() + return errors @@ -901,24 +944,29 @@ def addTextItems(mdl, odict, parent=None): for k in odict: # In case k is a folder: - if type(odict[k]) == OrderedDict and "folder.txt" in odict[k]: + if (type(odict[k]) == OrderedDict) and ("folder.txt" in odict[k]): # Adds folder - log("{}* Adds {} to {} (folder)".format(" " * parent.level(), k, parent.title())) + LOGGER.debug("{}* Adds {} to {} (folder)".format(" " * parent.level(), k, parent.title())) item = outlineFromMMD(odict[k]["folder.txt"], parent=parent) item._lastPath = odict[k + ":lastPath"] # Read content addTextItems(mdl, odict[k], parent=item) - # k is not a folder - elif type(odict[k]) == str and k != "folder.txt" and not ":lastPath" in k: - log("{}* Adds {} to {} (file)".format(" " * parent.level(), k, parent.title())) - item = outlineFromMMD(odict[k], parent=parent) - item._lastPath = odict[k + ":lastPath"] + if (":lastPath" in k) or (k == "folder.txt"): + continue - elif not ":lastPath" in k and k != "folder.txt": - print("* Strange things in file {}".format(k)) + # k is not a folder + if type(odict[k]) == str: + try: + LOGGER.debug("{}* Adds {} to {} (file)".format(" " * parent.level(), k, parent.title())) + item = outlineFromMMD(odict[k], parent=parent) + item._lastPath = odict[k + ":lastPath"] + except KeyError: + LOGGER.error("Failed to add file " + str(k)) + else: + LOGGER.debug("Strange things in file %s".format(k)) def outlineFromMMD(text, parent): @@ -929,9 +977,11 @@ def outlineFromMMD(text, parent): @return: outlineItem """ - item = outlineItem(parent=parent) md, body = parseMMDFile(text, asDict=True) + # Assign ID on creation, to avoid generating a new ID for this object + item = outlineItem(parent=parent, ID=md.pop('ID')) + # Store metadata for k in md: if k in Outline.__members__: @@ -968,17 +1018,19 @@ def appendRevisions(mdl, root): # Get root's ID ID = root.attrib["ID"] if not ID: - log("* Serious problem: no ID!") + LOGGER.debug("* Serious problem: no ID!") + LOGGER.error("Revision has no ID associated!") continue # Find outline item in model item = mdl.getItemByID(ID) if not item: - log("* Error: no item whose ID is", ID) + LOGGER.debug("* Error: no item whose ID is %s", ID) + LOGGER.error("Could not identify the item matching the revision ID.") continue # Store revision - log("* Appends revision ({}) to {}".format(child.attrib["timestamp"], item.title())) + LOGGER.debug("* Appends revision ({}) to {}".format(child.attrib["timestamp"], item.title())) item.appendRevision(child.attrib["timestamp"], child.attrib["text"]) @@ -990,7 +1042,7 @@ def getOutlineItem(item, enum): @return: [QStandardItem] """ row = getStandardItemRowFromXMLEnum(item, enum) - log("* Add worldItem:", row[0].text()) + LOGGER.debug("* Add worldItem: %s", row[0].text()) for child in item: sub = getOutlineItem(child, enum) row[0].appendRow(sub) diff --git a/manuskript/logging.py b/manuskript/logging.py new file mode 100644 index 0000000..5555e73 --- /dev/null +++ b/manuskript/logging.py @@ -0,0 +1,379 @@ +# -*- coding: utf-8 -*- + +# While all logging should be done through the facilities offered by the +# standard python `logging` module, this module will take care of specific +# manuskript needs to keep it separate from the rest of the logic. + +import os +import sys +import time +import logging +import pathlib + +from manuskript.functions import writablePath +from importlib import import_module +from pprint import pformat + +LOGGER = logging.getLogger(__name__) + +LOGFORMAT_CONSOLE = "%(levelname)s> %(message)s" +LOGFORMAT_FILE = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + +def setUp(console_level=logging.WARN): + """Sets up a convenient environment for logging. + + To console: >WARNING, plain. (Only the essence.)""" + + # The root_logger should merely trigger on warnings since it is the final + # stop after all categories we really care about didn't match. + root_logger = logging.getLogger() + root_logger.setLevel(logging.WARN) + # The manuskript_logger is what all of our own code will come by. + # Obviously, we care greatly about logging every single message. + manuskript_logger = logging.getLogger("manuskript") + manuskript_logger.setLevel(logging.DEBUG) + # The qt_logger sees all the Qt nonsense when it breaks. + # We don't really want to know... but we have to know. + qt_logger = logging.getLogger("qt") + qt_logger.setLevel(logging.DEBUG) + + # Send logs of WARNING+ to STDERR for higher visibility. + ch = logging.StreamHandler() + ch.setLevel(console_level) + ch.setFormatter(logging.Formatter(LOGFORMAT_CONSOLE)) + root_logger.addHandler(ch) + + # Any exceptions we did not account for need to be logged. + logFutureExceptions() + + LOGGER.debug("Logging to STDERR.") + + +def getDefaultLogFile(): + """Returns a filename to log to inside {datadir}/logs/. + + It also prunes old logs so that we do not hog disk space excessively over time. + """ + # Ensure logs directory exists. + logsPath = os.path.join(writablePath(), "logs") + os.makedirs(logsPath, exist_ok=True) + # Prune irrelevant log files. They are only kept for 35 days. + try: # Guard against os.scandir() in the name of paranoia. + now = time.time() + with os.scandir(logsPath) as it: + for f in it: + try: # Avoid triggering outer try-except inside loop. + if f.is_dir(): + continue # If a subdirectory exists for whatever reason, don't touch it. + if (now - f.stat().st_ctime) // (24 * 3600) >= 35: + os.remove(f) + except OSError: + continue # Fail silently, but make sure we check other files. + except OSError: + pass # Fail silently. Don't explode and prevent Manuskript from starting. + return os.path.join(logsPath, "%Y-%m-%d_%H-%M-%S_manuskript#%#.log") + + +def formatLogName(formatString, pid=None, now=None): + """A minor hack on top of `strftime()` to support an identifier for the process ID. + + We want to support this in case some genius manages to start two manuskript processes + during the exact same second, causing a conflict in log filenames. + + Additionally, there is a tiny chance that the pid could actually end up relevant when + observing strange behaviour with a Manuskript process but having multiple instances open. + """ + if pid == None: + pid = os.getpid() + if now == None: + now = time.localtime() + + # Replace %# that is NOT preceded by %. Although this is not a perfect solution, + # it is good enough because it is unlikely anyone would want to format '%pid'. + lidx = 0 + while True: # This could be neater with the := operator of Python 3.8 ... + fidx = formatString.find("%#", lidx) + if fidx == -1: + break + elif (fidx == 0) or (formatString[fidx-1] != "%"): + formatString = formatString[:fidx] + str(pid) + formatString[fidx+2:] + lidx = fidx + len(str(pid)) - 2 + else: # skip and avoid endless loop + lidx = fidx + 1 + + # Finally apply strftime normally. + return time.strftime(formatString, now) + + +def logToFile(file_level=logging.DEBUG, logfile=None): + """Sets up the FileHandler that logs to a file. + + This is being done separately due to relying on QApplication being properly + configured; without it we cannot detect the proper location for the log file. + + To log file: >DEBUG, timestamped. (All the details.)""" + + if logfile is None: + logfile = getDefaultLogFile() + + logfile = formatLogName(logfile) + + # Log with extreme prejudice; everything goes to the log file. + # Because Qt gave me a megabyte-sized logfile while testing, it + # makes sense that the default behaviour of appending to existing + # log files may not be in our users best interest for the time + # being. (Unfortunately.) + try: + fh = logging.FileHandler(logfile, mode='w', encoding='utf-8') + fh.setLevel(file_level) + fh.setFormatter(logging.Formatter(LOGFORMAT_FILE)) + + root_logger = logging.getLogger() + root_logger.addHandler(fh) + + # Use INFO level to make it easier to find for users. + LOGGER.info("Logging to file: %s", logfile) + except Exception as ex: + LOGGER.warning("Cannot log to file '%s'. Reason: %s", logfile, ex) + + +def getLogFilePath(): + """Extracts a filename we are logging to from the first FileHandler we find.""" + root_logger = logging.getLogger() + for handler in root_logger.handlers: + if isinstance(handler, logging.FileHandler): + return handler.baseFilename + return None + + +# Log uncaught and unraisable exceptions. + +# Uncaught exceptions trigger moments before a thread is terminated due to +# an uncaught exception. It is the final stop, and as such is very likely +# to be the reason Manuskript suddenly closed on the user without warning. +# (It can also happen on other threads, but it is a bad thing regardless!) +def handle_uncaught_exception(exc_type, exc_value, exc_traceback): + # Allow Ctrl+C for script execution to keep functioning as-is. + if issubclass(exc_type, KeyboardInterrupt): + sys.__excepthook__(exc_type, exc_value, exc_traceback) + return # default exception hook handled it + + # Anything that reaches this handler can be considered a deal-breaker. + LOGGER.critical("An unhandled exception has occurred!", exc_info=(exc_type, exc_value, exc_traceback)) + + # Exit the program to preserve PyQt 'functionality' that is broken by + # having our own uncaught exception hook. For more information, see: + # https://stackoverflow.com/questions/49065371/why-does-sys-excepthook-behave-differently-when-wrapped + sys.exit(1) + + # Note that without it, unhandled Python exceptions thrown while in the + # bowels of Qt may be written to the log multiple times. Under the motto + # of failing fast and not having a misleading log file, this appears to + # be the best course of action. + + +# The situation with threads and uncaught exceptions is fraught in peril. +# Hopefully this solves our problems on more recent versions of Python. +def handle_uncaught_thread_exception(args): + if issubclass(exc_type, SystemExit): + return # match behaviour of default hook, see manual + + # Anything that reaches this handler can be considered a minor deal-breaker. + LOGGER.error("An unhandled exception has occurred in a thread: %s", repr(args.thread), + exc_info=(args.exc_type, args.exc_value, args.exc_traceback)) + + +# Unraisable exceptions are exceptions that failed to be raised to a caller +# due to the nature of the exception. Examples: __del__(), GC error, etc. +# Logging these may expose bugs / errors that would otherwise go unnoticed. +def handle_unraisable_exception(unraisable): + # Log as warning because the application is likely to limp along with + # no serious side effects; a resource leak is the most likely. + LOGGER.warning("%s: %s", unraisable.err_msg or "Exception ignored in", repr(unraisable.object), + exc_info=(unraisable.exc_type, unraisable.exc_value, unraisable.exc_traceback)) + + +# Because we are already somewhat careful in regards to the order of code +# execution when it comes to setting up the logging environment, this has +# been put in its own function as opposed to letting a direct import handle it. +def logFutureExceptions(): + """Log all the interesting exceptions that may happen in the future.""" + sys.excepthook = handle_uncaught_exception + try: + import threading # threading module was optional pre-3.7 + if hasattr(threading, "excepthook"): # Python 3.8+ + threading.excepthook = handle_uncaught_thread_exception + except: + pass + if hasattr(sys, "unraisablehook"): # Python 3.8+ + sys.unraisablehook = handle_unraisable_exception + + +# Qt has its own logging facility that we would like to integrate into our own. +# See: http://thispageintentionally.blogspot.com/2014/03/trapping-qt-log-messages.html + +from PyQt5.QtCore import qInstallMessageHandler, QLibraryInfo, QMessageLogContext +from PyQt5.Qt import QtMsgType + +def qtMessageHandler(msg_type, msg_log_context, msg_string): + """Forwards Qt messages to Python logging system.""" + # Convert Qt msg type to logging level + msg_type_index = int(msg_type) + log_levels = [logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR, logging.FATAL] + if (msg_type_index >= 0) and (msg_type_index < len(log_levels)): + log_level = log_levels[msg_type_index] + else: + log_level = log_levels[-1] + qtcl = logging.getLogger(msg_log_context.category or "qt.???") + # Some information may not be available unless using a PyQt debug build. + # See: https://www.riverbankcomputing.com/static/Docs/PyQt5/api/qtcore/qmessagelogcontext.html + if QLibraryInfo.isDebugBuild(): + qtcl.log(logging.DEBUG, + ' @ {0} : {1}'.format((msg_log_context.file or ""), msg_log_context.line) + ) + qtcl.log(logging.DEBUG, + ' ! {0}'.format((msg_log_context.function or " ")) + ) + qtcl.log(log_level, msg_string) + +def integrateQtLogging(): + """Integrates Qt logging facilities to be a part of our own.""" + + # Note: the qtlogger is initialized in setUp() because it fits in + # nicely with the initialization of the other loggers over there. + # I also feel a lot safer this way. Qt is a curse that just keeps + # on giving, even when it isn't actually at fault. I hate you, Qt. + + qInstallMessageHandler(qtMessageHandler) + + +def versionTupleToString(t): + """A bit of generic tuple conversion code that hopefully handles all the + different sorts of tuples we may come across while logging versions. + + None -> "N/A" + (,) -> "N/A" + (2, 4, 6) -> "2.4.6" + (2, 4, "alpha", 8) -> "2.4-alpha.8" + """ + + s = [] + if t is None or len(t) == 0: + return "N/A" + else: + s.append(str(t[0])) + + def version_chunk(v): + if isinstance(v, str): + return "-", str(v) + else: + return ".", str(v) + + s.extend(f for p in t[1:] for f in version_chunk(p)) + return "".join(s) + +def attributesFromOptionalModule(module, *attributes): + """It is nice to cut down on the try-except boilerplate by + putting this logic into its own function. + + Returns as many values as there are attributes. + A value will be None if it failed to get the attribute.""" + + assert(len(attributes) != 0) + v = [] + try: + m = import_module(module) + + for a in attributes: + v.append(getattr(m, a, None)) + except ImportError: + v.extend(None for _ in range(len(attributes))) + + if len(v) == 1: + # Return the value directly so we can use it in an expression. + return v[0] + else: + # The list is consumed as a part of the unpacking syntax. + return v + +def logRuntimeInformation(logger=None): + """Logs all important runtime information neatly together. + + Due to the generic nature, use the manuskript logger by default.""" + + if not logger: + logger = logging.getLogger("manuskript") + + vt2s = versionTupleToString + afom = attributesFromOptionalModule + + # Basic system information. + from platform import python_version, platform, processor, machine + logger.info("Operating System: %s", platform()) + logger.info("Hardware: %s / %s", machine(), processor()) + + # Information about the running instance. See: + # https://pyinstaller.readthedocs.io/en/v3.3.1/runtime-information.html + # http://www.py2exe.org/index.cgi/Py2exeEnvironment + # https://cx-freeze.readthedocs.io/en/latest/faq.html#data-files + frozen = getattr(sys, 'frozen', False) + if frozen: + logger.info("Running in a frozen (packaged) state.") + logger.debug("* sys.frozen = %s", pformat(frozen)) + + # PyInstaller, py2exe and cx_Freeze modules are not accessible while frozen, + # so logging their version is (to my knowledge) impossible without including + # special steps into the distribution process. But some traces do exist... + logger.debug("* sys._MEIPASS = %s", getattr(sys, '_MEIPASS', "N/A")) # PyInstaller bundle + # cx_Freeze and py2exe do not appear to leave anything similar exposed. + else: + logger.info("Running from unpackaged source code.") + + # File not found? These bits of information might help. + logger.debug("* sys.executable = %s", pformat(sys.executable)) + logger.debug("* sys.argv = %s", pformat(sys.argv)) + logger.debug("* sys.path = %s", pformat(sys.path)) + logger.debug("* sys.prefix = %s", pformat(sys.prefix)) + + # Manuskript and Python info. + from manuskript.functions import getGitRevisionAsString, getManuskriptPath + from manuskript.version import getVersion + logger.info("Manuskript %s%s (Python %s)", getVersion(), + getGitRevisionAsString(getManuskriptPath(), short=True), + python_version()) + + # Installed Python packages. + + # PyQt + Qt + from PyQt5.Qt import PYQT_VERSION_STR, qVersion + from PyQt5.QtCore import QT_VERSION_STR + logger.info("* PyQt %s (compiled against Qt %s)", PYQT_VERSION_STR, QT_VERSION_STR) + logger.info(" * Qt %s (runtime)", qVersion()) + + # Lxml + # See: https://lxml.de/FAQ.html#i-think-i-have-found-a-bug-in-lxml-what-should-i-do + from lxml import etree + logger.info("* lxml.etree %s", vt2s(etree.LXML_VERSION)) + logger.info(" * libxml %s (compiled: %s)", vt2s(etree.LIBXML_VERSION), vt2s(etree.LIBXML_COMPILED_VERSION)) + logger.info(" * libxslt %s (compiled: %s)", vt2s(etree.LIBXSLT_VERSION), vt2s(etree.LIBXSLT_COMPILED_VERSION)) + + # Spellcheckers. (Optional) + enchant_mod_ver, enchant_lib_ver = afom("enchant", "__version__", "get_enchant_version") + if enchant_lib_ver: + enchant_lib_ver = enchant_lib_ver() + if isinstance(enchant_lib_ver, bytes): # PyEnchant version < 3.0.2 + enchant_lib_ver = enchant_lib_ver.decode('utf-8') + logger.info("* pyEnchant %s (libenchant: %s)", enchant_mod_ver or "N/A", enchant_lib_ver or "N/A") + + logger.info("* pySpellChecker %s", afom("spellchecker", "__version__") or "N/A") + logger.info("* Symspellpy %s", afom("symspellpy", "__version__") or "N/A") + + # Markdown. (Optional) + logger.info("* Markdown %s", afom("markdown", "__version__") or "N/A") + + # Web rendering engine + from manuskript.ui.views.webView import webEngine + logger.info("Web rendering engine: %s", webEngine) + + # Do not collect version information for Pandoc; that would require + # executing `pandov -v` and parsing the output, all of which is too slow. diff --git a/manuskript/main.py b/manuskript/main.py index 294ba98..3f36d55 100644 --- a/manuskript/main.py +++ b/manuskript/main.py @@ -2,26 +2,47 @@ import faulthandler import os +import platform import sys +import signal -import manuskript.ui.views.webView -from PyQt5.QtCore import QLocale, QTranslator, QSettings -from PyQt5.QtGui import QIcon -from PyQt5.QtWidgets import QApplication, qApp +import manuskript.logging +from PyQt5.QtCore import QLocale, QTranslator, QSettings, Qt +from PyQt5.QtGui import QIcon, QColor, QPalette +from PyQt5.QtWidgets import QApplication, qApp, QStyleFactory from manuskript.functions import appPath, writablePath from manuskript.version import getVersion -faulthandler.enable() +try: + faulthandler.enable() +except AttributeError: + print("Faulthandler failed") -def prepare(tests=False): +import logging +LOGGER = logging.getLogger(__name__) + +def prepare(arguments, tests=False): + # Qt WebEngine demands this attribute be set _before_ we create our QApplication object. + QApplication.setAttribute(Qt.AA_ShareOpenGLContexts, True) + + # Create the foundation that provides our Qt application with its event loop. app = QApplication(sys.argv) - app.setOrganizationName("manuskript"+("_tests" if tests else "")) + app.setOrganizationName("manuskript" + ("_tests" if tests else "")) app.setOrganizationDomain("www.theologeek.ch") - app.setApplicationName("manuskript"+("_tests" if tests else "")) + app.setApplicationName("manuskript" + ("_tests" if tests else "")) app.setApplicationVersion(getVersion()) - print("Running manuskript version {}.".format(getVersion())) + # Beginning logging to a file. This cannot be done earlier due to the + # default location of the log file being dependent on QApplication. + manuskript.logging.logToFile(logfile=arguments.logfile) + + # Handle all sorts of Qt logging messages in Python. + manuskript.logging.integrateQtLogging() + + # Log all the versions for less headaches. + manuskript.logging.logRuntimeInformation() + icon = QIcon() for i in [16, 32, 64, 128, 256, 512]: icon.addFile(appPath("icons/Manuskript/icon-{}px.png".format(i))) @@ -36,40 +57,117 @@ def prepare(tests=False): app.setStyle(style) # Translation process - locale = QLocale.system().name() - appTranslator = QTranslator(app) + # By default: locale - def extractLocale(filename): - # len("manuskript_") = 13, len(".qm") = 3 - return filename[11:-3] if len(filename) >= 16 else "" - def tryLoadTranslation(translation, source): - if appTranslator.load(appPath(os.path.join("i18n", translation))): + """Tries to load and activate a given translation for use.""" + if appTranslator.load(translation, appPath("i18n")): app.installTranslator(appTranslator) - print(app.tr("Loaded translation from {}: {}.").format(source, translation)) + LOGGER.info("Loaded translation: {}".format(translation)) + # Note: QTranslator.load() does some fancy heuristics where it simplifies + # the given locale until it is 'close enough' if the given filename does + # not work out. For example, if given 'i18n/manuskript_en_US.qm', it tries: + # * i18n/manuskript_en_US.qm.qm + # * i18n/manuskript_en_US.qm + # * i18n/manuskript_en_US + # * i18n/manuskript_en.qm + # * i18n/manuskript_en + # * i18n/manuskript.qm + # * i18n/manuskript + # We have no way to determining what it eventually went with, so mind your + # filenames when you observe strange behaviour with the loaded translations. return True else: - print(app.tr("Note: No translator found or loaded from {} for locale {}."). - format(source, extractLocale(translation))) + LOGGER.info("No translation found or loaded. ({})".format(translation)) return False - # Load translation from settings - translation = "" - if settings.contains("applicationTranslation"): - translation = settings.value("applicationTranslation") - print("Found translation in settings:", translation) + def activateTranslation(translation, source): + """Loads the most suitable translation based on the available information.""" + using_builtin_translation = True - if (translation != "" and not tryLoadTranslation(translation, "settings")) or translation == "": - # load from settings failed or not set, fallback - translation = "manuskript_{}.qm".format(locale) - tryLoadTranslation(translation, "system locale") + if (translation != ""): # empty string == 'no translation, use builtin' + if isinstance(translation, str): + if tryLoadTranslation(translation, source): + using_builtin_translation = False + else: # A list of language codes to try. Once something works, we're done. + # This logic is loosely based on the working of QTranslator.load(QLocale, ...); + # it allows us to more accurately detect the language used for the user interface. + for language_code in translation: + lc = language_code.replace('-', '_') + if lc.lower() == 'en_US'.lower(): + break + if tryLoadTranslation("manuskript_{}.qm".format(lc), source): + using_builtin_translation = False + break + + if using_builtin_translation: + LOGGER.info("Using the builtin translation. (U.S. English)") + + # Load application translation + translation = "" + source = "default" + if settings.contains("applicationTranslation"): + # Use the language configured by the user. + translation = settings.value("applicationTranslation") + source = "user setting" + else: + # Auto-detect based on system locale. + translation = QLocale().uiLanguages() + source = "available ui languages" + + LOGGER.info("Preferred translation: {} (based on {})".format(("builtin" if translation == "" else translation), source)) + activateTranslation(translation, source) + + def respectSystemDarkThemeSetting(): + """Adjusts the Qt theme to match the OS 'dark theme' setting configured by the user.""" + if platform.system() != 'Windows': + return + + # Basic Windows 10 Dark Theme support. + # Source: https://forum.qt.io/topic/101391/windows-10-dark-theme/4 + themeSettings = QSettings( + "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + QSettings.NativeFormat) + if themeSettings.value("AppsUseLightTheme") == 0: + darkPalette = QPalette() + darkColor = QColor(45, 45, 45) + disabledColor = QColor(127, 127, 127) + darkPalette.setColor(QPalette.Window, darkColor) + darkPalette.setColor(QPalette.WindowText, Qt.white) + darkPalette.setColor(QPalette.Base, QColor(18, 18, 18)) + darkPalette.setColor(QPalette.AlternateBase, darkColor) + darkPalette.setColor(QPalette.ToolTipBase, Qt.white) + darkPalette.setColor(QPalette.ToolTipText, Qt.white) + darkPalette.setColor(QPalette.Text, Qt.white) + darkPalette.setColor(QPalette.Disabled, QPalette.Text, disabledColor) + darkPalette.setColor(QPalette.Button, darkColor) + darkPalette.setColor(QPalette.ButtonText, Qt.white) + darkPalette.setColor(QPalette.Disabled, QPalette.ButtonText, disabledColor) + darkPalette.setColor(QPalette.BrightText, Qt.red) + darkPalette.setColor(QPalette.Link, QColor(42, 130, 218)) + + darkPalette.setColor(QPalette.Highlight, QColor(42, 130, 218)) + darkPalette.setColor(QPalette.HighlightedText, Qt.black) + darkPalette.setColor(QPalette.Disabled, QPalette.HighlightedText, disabledColor) + + # Fixes ugly (not to mention hard to read) disabled menu items. + # Source: https://bugreports.qt.io/browse/QTBUG-10322?focusedCommentId=371060#comment-371060 + darkPalette.setColor(QPalette.Disabled, QPalette.Light, Qt.transparent) + + app.setPalette(darkPalette) + + # This broke the Settings Dialog at one point... and then it stopped breaking it. + # TODO: Why'd it break? Check if tooltips look OK... and if not, make them look OK. + # app.setStyleSheet("QToolTip { color: #ffffff; background-color: #2a82da; border: 1px solid white; }") + + respectSystemDarkThemeSetting() QIcon.setThemeSearchPaths(QIcon.themeSearchPaths() + [appPath("icons")]) QIcon.setThemeName("NumixMsk") - # Font siue + # Font size if settings.contains("appFontSize"): f = qApp.font() f.setPointSize(settings.value("appFontSize", type=int)) @@ -84,34 +182,135 @@ def prepare(tests=False): MW._defaultCursorFlashTime = qApp.cursorFlashTime() # Command line project - if len(sys.argv) > 1 and sys.argv[1][-4:] == ".msk": - if os.path.exists(sys.argv[1]): - path = os.path.abspath(sys.argv[1]) - MW._autoLoadProject = path + if arguments.filename is not None and arguments.filename[-4:] == ".msk": + # The file is verified to already exist during argument parsing. + # Our ".msk" check has been moved there too for better feedback, + # but leaving it here to err on the side of caution. + path = os.path.abspath(arguments.filename) + MW._autoLoadProject = path return app, MW -def launch(MW = None): - if MW is None: +def launch(arguments, app, MW = None): + if MW == None: from manuskript.functions import mainWindow MW = mainWindow() MW.show() - qApp.exec_() + # Support for IPython Jupyter QT Console as a debugging aid. + # Last argument must be --console to enable it + # Code reference : + # https://github.com/ipython/ipykernel/blob/master/examples/embedding/ipkernel_qtapp.py + # https://github.com/ipython/ipykernel/blob/master/examples/embedding/internal_ipkernel.py + if arguments.console: + try: + from IPython.lib.kernel import connect_qtconsole + from ipykernel.kernelapp import IPKernelApp + # Only to ensure matplotlib QT mainloop integration is available + import matplotlib + + # Create IPython kernel within our application + kernel = IPKernelApp.instance() + + # Initialize it and use matplotlib for main event loop integration with QT + kernel.initialize(['python', '--matplotlib=qt']) + + # Create the console in a new process and connect + console = connect_qtconsole(kernel.abs_connection_file, profile=kernel.profile) + + # Export MW and app variable to the console's namespace + kernel.shell.user_ns['MW'] = MW + kernel.shell.user_ns['app'] = app + kernel.shell.user_ns['kernel'] = kernel + kernel.shell.user_ns['console'] = console + + # When we close manuskript, make sure we close the console process and stop the + # IPython kernel's mainloop, otherwise the app will never finish. + def console_cleanup(): + app.quit() + console.kill() + kernel.io_loop.stop() + + app.lastWindowClosed.connect(console_cleanup) + + # Very important, IPython-specific step: this gets GUI event loop + # integration going, and it replaces calling app.exec_() + kernel.start() + except Exception as e: + print("Console mode requested but error initializing IPython : %s" % str(e)) + print("To make use of the Interactive IPython QT Console, make sure you install : ") + print("$ pip3 install ipython qtconsole matplotlib") + qApp.exec_() + else: + qApp.exec_() qApp.deleteLater() + +def sigint_handler(sig, MW): + def handler(*args): + # Log before winding down to preserve order of cause and effect. + LOGGER.info(f'{sig} received. Quitting...') + MW.close() + print(f'{sig} received, quit.') + + return handler + + +def setup_signal_handlers(MW): + signal.signal(signal.SIGINT, sigint_handler("SIGINT", MW)) + signal.signal(signal.SIGTERM, sigint_handler("SIGTERM", MW)) + + +def is_valid_project(parser, arg): + if arg[-4:] != ".msk": + parser.error("only manuskript projects (.msk) are supported!") + if not os.path.isfile(arg): + parser.error("the project %s does not exist!" % arg) + else: + return arg + + +def process_commandline(argv): + import argparse + parser = argparse.ArgumentParser(description="Run the manuskript application.") + parser.add_argument("--console", help="open the IPython Jupyter QT Console as a debugging aid", + action="store_true") + parser.add_argument("-v", "--verbose", action="count", default=1, help="lower the threshold for messages logged to the terminal") + parser.add_argument("-L", "--logfile", default=None, help="override the default log file location") + parser.add_argument("filename", nargs="?", metavar="FILENAME", help="the manuskript project (.msk) to open", + type=lambda x: is_valid_project(parser, x)) + + args = parser.parse_args(args=argv) + + # Verbosity logic, see: https://gist.github.com/ms5/9f6df9c42a5f5435be0e + #args.verbose = 70 - (10*args.verbose) if args.verbose > 0 else 0 + + # Users cannot report what they do not notice: show CRITICAL, ERROR and WARNING always. + # Note that the default is set to 1, so account for that. + args.verbose = 40 - (10*args.verbose) if args.verbose > 0 else 0 + + return args + + def run(): """ Run separates prepare and launch for two reasons: 1. I've read somewhere it helps with potential segfault (see comment below) 2. So that prepare can be used in tests, without running the whole thing """ + # Parse command-line arguments. + arguments = process_commandline(sys.argv[1:]) + # Initialize logging. (Does not include Qt integration yet.) + manuskript.logging.setUp(console_level=arguments.verbose) + # Need to return and keep `app` otherwise it gets deleted. - app, MW = prepare() + app, MW = prepare(arguments) + setup_signal_handlers(MW) # Separating launch to avoid segfault, so it seem. # Cf. http://stackoverflow.com/questions/12433491/is-this-pyqt-4-python-bug-or-wrongly-behaving-code - launch(MW) + launch(arguments, app, MW) + if __name__ == "__main__": run() diff --git a/manuskript/mainWindow.py b/manuskript/mainWindow.py index 8dae33d..5a02fbc 100644 --- a/manuskript/mainWindow.py +++ b/manuskript/mainWindow.py @@ -1,19 +1,22 @@ #!/usr/bin/env python # --!-- coding: utf8 --!-- -import imp +import importlib import os +import re -from PyQt5.QtCore import (pyqtSignal, QSignalMapper, QTimer, QSettings, Qt, +from PyQt5.Qt import qVersion, PYQT_VERSION_STR +from PyQt5.QtCore import (pyqtSignal, QSignalMapper, QTimer, QSettings, Qt, QPoint, QRegExp, QUrl, QSize, QModelIndex) from PyQt5.QtGui import QStandardItemModel, QIcon, QColor from PyQt5.QtWidgets import QMainWindow, QHeaderView, qApp, QMenu, QActionGroup, QAction, QStyle, QListWidgetItem, \ - QLabel, QDockWidget, QWidget + QLabel, QDockWidget, QWidget, QMessageBox, QLineEdit from manuskript import settings from manuskript.enums import Character, PlotStep, Plot, World, Outline -from manuskript.functions import wordCount, appPath, findWidgetsOfClass +from manuskript.functions import wordCount, appPath, findWidgetsOfClass, openURL, showInFolder import manuskript.functions as F from manuskript import loadSave +from manuskript.logging import getLogFilePath from manuskript.models.characterModel import characterModel from manuskript.models import outlineModel from manuskript.models.plotModel import plotModel @@ -35,15 +38,13 @@ from manuskript.ui.statusLabel import statusLabel # Spellcheck support from manuskript.ui.views.textEditView import textEditView +from manuskript.functions import Spellchecker -try: - import enchant -except ImportError: - enchant = None - +import logging +LOGGER = logging.getLogger(__name__) class MainWindow(QMainWindow, Ui_MainWindow): - dictChanged = pyqtSignal(str) + # dictChanged = pyqtSignal(str) # Tab indexes TabInfos = 0 @@ -63,9 +64,10 @@ class MainWindow(QMainWindow, Ui_MainWindow): # Var self.currentProject = None + self.projectDirty = None # has the user made any unsaved changes ? self._lastFocus = None self._lastMDEditView = None - self._defaultCursorFlashTime = 1000 # Overriden at startup with system + self._defaultCursorFlashTime = 1000 # Overridden at startup with system # value. In manuskript.main. self._autoLoadProject = None # Used to load a command line project self.sessionStartWordCount = 0 # Used to track session targets @@ -94,22 +96,6 @@ class MainWindow(QMainWindow, Ui_MainWindow): self.mprWordCount.setMapping(t, i) self.mprWordCount.mapped.connect(self.wordCount) - # Snowflake Method Cycle - self.mapperCycle = QSignalMapper(self) - for t, i in [ - (self.btnStepTwo, 0), - (self.btnStepThree, 1), - (self.btnStepFour, 2), - (self.btnStepFive, 3), - (self.btnStepSix, 4), - (self.btnStepSeven, 5), - (self.btnStepEight, 6) - ]: - t.clicked.connect(self.mapperCycle.map) - self.mapperCycle.setMapping(t, i) - - self.mapperCycle.mapped.connect(self.clickCycle) - self.cmbSummary.currentIndexChanged.connect(self.summaryPageChanged) self.cmbSummary.setCurrentIndex(0) self.cmbSummary.currentIndexChanged.emit(0) @@ -133,6 +119,7 @@ class MainWindow(QMainWindow, Ui_MainWindow): self.actCopy.triggered.connect(self.documentsCopy) self.actCut.triggered.connect(self.documentsCut) self.actPaste.triggered.connect(self.documentsPaste) + self.actSearch.triggered.connect(self.doSearch) self.actRename.triggered.connect(self.documentsRename) self.actDuplicate.triggered.connect(self.documentsDuplicate) self.actDelete.triggered.connect(self.documentsDelete) @@ -180,6 +167,8 @@ class MainWindow(QMainWindow, Ui_MainWindow): # Main Menu:: Tool self.actToolFrequency.triggered.connect(self.frequencyAnalyzer) self.actToolTargets.triggered.connect(self.sessionTargets) + self.actSupport.triggered.connect(self.support) + self.actLocateLog.triggered.connect(self.locateLogFile) self.actAbout.triggered.connect(self.about) self.makeUIConnections() @@ -279,25 +268,20 @@ class MainWindow(QMainWindow, Ui_MainWindow): self.mainEditor ] - while new is not None: + while new != None: if new in targets: self._lastFocus = new break new = new.parent() - ############################################################################### - # SUMMARY - ############################################################################### - - def summaryPageChanged(self, index): - fractalButtons = [ - self.btnStepTwo, - self.btnStepThree, - self.btnStepFive, - self.btnStepSeven, - ] - for b in fractalButtons: - b.setVisible(fractalButtons.index(b) == index) + def projectName(self): + """ + Returns a user-friendly name for the loaded project. + """ + pName = os.path.split(self.currentProject)[1] + if pName.endswith('.msk'): + pName=pName[:-4] + return pName ############################################################################### # OUTLINE @@ -346,6 +330,9 @@ class MainWindow(QMainWindow, Ui_MainWindow): # Slider importance self.updateCharacterImportance(c.ID()) + # POV state + self.updateCharacterPOVState(c.ID()) + # Character Infos self.tblPersoInfos.setRootIndex(index) @@ -366,6 +353,31 @@ class MainWindow(QMainWindow, Ui_MainWindow): c = self.mdlCharacter.getCharacterByID(ID) self.sldPersoImportance.setValue(int(c.importance())) + def updateCharacterPOVState(self, ID): + c = self.mdlCharacter.getCharacterByID(ID) + self.disconnectAll(self.chkPersoPOV.stateChanged, self.lstCharacters.changeCharacterPOVState) + + if c.pov(): + self.chkPersoPOV.setCheckState(Qt.Checked) + else: + self.chkPersoPOV.setCheckState(Qt.Unchecked) + + try: + self.chkPersoPOV.stateChanged.connect(self.lstCharacters.changeCharacterPOVState, F.AUC) + self.chkPersoPOV.setEnabled(len(self.mdlOutline.findItemsByPOV(ID)) == 0) + except TypeError: + #don't know what's up with this + pass + + def deleteCharacter(self): + ID = self.lstCharacters.removeCharacter() + if ID is None: + return + for itemID in self.mdlOutline.findItemsByPOV(ID): + item = self.mdlOutline.getItemByID(itemID) + if item: + item.resetPOV() + ############################################################################### # PLOTS ############################################################################### @@ -480,6 +492,13 @@ class MainWindow(QMainWindow, Ui_MainWindow): def documentsPaste(self): "Paste clipboard item(s) into selected item." if self._lastFocus: self._lastFocus.paste() + def doSearch(self): + "Do a global search." + self.dckSearch.show() + self.dckSearch.activateWindow() + searchTextInput = self.dckSearch.findChild(QLineEdit, 'searchTextInput') + searchTextInput.setFocus() + searchTextInput.selectAll() def documentsRename(self): "Rename selected item." if self._lastFocus: self._lastFocus.rename() @@ -557,14 +576,14 @@ class MainWindow(QMainWindow, Ui_MainWindow): If ``loadFromFile`` is False, then it does not load datas from file. It assumes that the datas have been populated in a different way.""" if loadFromFile and not os.path.exists(project): - print(self.tr("The file {} does not exist. Has it been moved or deleted?").format(project)) + LOGGER.warning("The file {} does not exist. Has it been moved or deleted?".format(project)) F.statusMessage( self.tr("The file {} does not exist. Has it been moved or deleted?").format(project), importance=3) return if loadFromFile: # Load empty settings - imp.reload(settings) + importlib.reload(settings) settings.initDefaultValues() # Load data @@ -612,7 +631,6 @@ class MainWindow(QMainWindow, Ui_MainWindow): self.mdlCharacter.dataChanged.connect(self.startTimerNoChanges) self.mdlPlots.dataChanged.connect(self.startTimerNoChanges) self.mdlWorld.dataChanged.connect(self.startTimerNoChanges) - # self.mdlPersosInfos.dataChanged.connect(self.startTimerNoChanges) self.mdlStatus.dataChanged.connect(self.startTimerNoChanges) self.mdlLabels.dataChanged.connect(self.startTimerNoChanges) @@ -630,42 +648,76 @@ class MainWindow(QMainWindow, Ui_MainWindow): # We force to emit even if it opens on the current tab self.tabMain.currentChanged.emit(settings.lastTab) - # Add project name to Window's name - pName = os.path.split(project)[1] - if pName.endswith('.msk'): - pName=pName[:-4] - self.setWindowTitle(pName + " - " + self.tr("Manuskript")) - - # Stuff - # self.checkPersosID() # Shouldn't be necessary any longer - + # Make sure we can update the window title later. self.currentProject = project + self.projectDirty = False QSettings().setValue("lastProject", project) item = self.mdlOutline.rootItem wc = item.data(Outline.wordCount) self.sessionStartWordCount = wc + # Add project name to Window's name + self.setWindowTitle(self.projectName() + " - " + self.tr("Manuskript")) # Show main Window self.switchToProject() + def handleUnsavedChanges(self): + """ + There may be some currently unsaved changes, but the action the user triggered + will result in the project or application being closed. To save, or not to save? + + Or just bail out entirely? + + Sometimes it is best to just ask. + """ + + if not self.projectDirty: + return True # no unsaved changes, all is good + + msg = QMessageBox(QMessageBox.Question, + self.tr("Save project?"), + " " + + self.tr("Save changes to project \"{}\" before closing?").format(self.projectName()) + + "
" + + "" + + self.tr("Your changes will be lost if you don't save them.") + + "
", + QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel) + + ret = msg.exec() + + if ret == QMessageBox.Cancel: + return False # the situation has not been handled, cancel action + + if ret == QMessageBox.Save: + self.saveDatas() + + return True # the situation has been handled + + def closeProject(self): if not self.currentProject: return + # Make sure data is saved. + if (self.projectDirty and settings.saveOnQuit == True): + self.saveDatas() + elif not self.handleUnsavedChanges(): + return # user cancelled action + # Close open tabs in editor self.mainEditor.closeAllTabs() - # Save datas - self.saveDatas() - self.currentProject = None + self.projectDirty = None QSettings().setValue("lastProject", "") # Clear datas self.loadEmptyDatas() self.saveTimer.stop() + self.saveTimerNoChanges.stop() loadSave.clearSaveCache() self.breakConnections() @@ -727,23 +779,6 @@ class MainWindow(QMainWindow, Ui_MainWindow): self._toolbarState = "" def closeEvent(self, event): - # Save State and geometry and other things - sttgns = QSettings(qApp.organizationName(), qApp.applicationName()) - sttgns.setValue("geometry", self.saveGeometry()) - sttgns.setValue("windowState", self.saveState()) - sttgns.setValue("metadataState", self.redacMetadata.saveState()) - sttgns.setValue("revisionsState", self.redacMetadata.revisions.saveState()) - sttgns.setValue("splitterRedacH", self.splitterRedacH.saveState()) - sttgns.setValue("splitterRedacV", self.splitterRedacV.saveState()) - sttgns.setValue("toolbar", self.toolbar.saveState()) - - # If we are not in the welcome window, we update the visibility - # of the docks widgets - if self.stack.currentIndex() == 1: - self.updateDockVisibility() - # Storing the visibility of docks to restore it on restart - sttgns.setValue("docks", self._dckVisibility) - # Specific settings to save before quitting settings.lastTab = self.tabMain.currentIndex() @@ -751,9 +786,15 @@ class MainWindow(QMainWindow, Ui_MainWindow): # Remembering the current items (stores outlineItem's ID) settings.openIndexes = self.mainEditor.tabSplitter.openIndexes() - # Save data from models - if self.currentProject and settings.saveOnQuit: - self.saveDatas() + # Call close on the main window to clean children widgets + if self.mainEditor: + self.mainEditor.close() + + # Save data from models + if settings.saveOnQuit: + self.saveDatas() + elif not self.handleUnsavedChanges(): + event.ignore() # user opted to cancel the close action # closeEvent # QMainWindow.closeEvent(self, event) # Causing segfaults? @@ -764,7 +805,33 @@ class MainWindow(QMainWindow, Ui_MainWindow): if self.fw: self.fw.close() + # User may have canceled close event, so make sure we indeed want to close. + # This is necessary because self.updateDockVisibility() hides UI elements. + if event.isAccepted(): + # Save State and geometry and other things + appSettings = QSettings(qApp.organizationName(), qApp.applicationName()) + appSettings.setValue("geometry", self.saveGeometry()) + appSettings.setValue("windowState", self.saveState()) + appSettings.setValue("metadataState", self.redacMetadata.saveState()) + appSettings.setValue("revisionsState", self.redacMetadata.revisions.saveState()) + appSettings.setValue("splitterRedacH", self.splitterRedacH.saveState()) + appSettings.setValue("splitterRedacV", self.splitterRedacV.saveState()) + appSettings.setValue("toolbar", self.toolbar.saveState()) + + # If we are not in the welcome window, we update the visibility + # of the docks widgets + if self.stack.currentIndex() == 1: + self.updateDockVisibility() + + # Storing the visibility of docks to restore it on restart + appSettings.setValue("docks", self._dckVisibility) + def startTimerNoChanges(self): + """ + Something changed in the project that requires auto-saving. + """ + self.projectDirty = True + if settings.autoSaveNoChanges: self.saveTimerNoChanges.start() @@ -779,25 +846,35 @@ class MainWindow(QMainWindow, Ui_MainWindow): self.currentProject = projectName QSettings().setValue("lastProject", projectName) - r = loadSave.saveProject() # version=0 + # Stop the timer before saving: if auto-saving fails (bugs out?) we don't want it + # to keep trying and continuously hitting the failure condition. Nor do we want to + # risk a scenario where the timer somehow triggers a new save while saving. self.saveTimerNoChanges.stop() + if self.currentProject == None: + # No UI feedback here as this code path indicates a race condition that happens + # after the user has already closed the project through some way. But in that + # scenario, this code should not be reachable to begin with. + LOGGER.error("There is no current project to save.") + return + + r = loadSave.saveProject() # version=0 + projectName = os.path.basename(self.currentProject) if r: + self.projectDirty = False # successful save, clear dirty flag + feedback = self.tr("Project {} saved.").format(projectName) F.statusMessage(feedback, importance=0) + LOGGER.info("Project {} saved.".format(projectName)) else: feedback = self.tr("WARNING: Project {} not saved.").format(projectName) F.statusMessage(feedback, importance=3) - - # Giving some feedback in console - print(feedback) + LOGGER.warning("Project {} not saved.".format(projectName)) def loadEmptyDatas(self): self.mdlFlatData = QStandardItemModel(self) self.mdlCharacter = characterModel(self) - # self.mdlPersosProxy = persosProxyModel(self) - # self.mdlPersosInfos = QStandardItemModel(self) self.mdlLabels = QStandardItemModel(self) self.mdlStatus = QStandardItemModel(self) self.mdlPlots = plotModel(self) @@ -810,13 +887,13 @@ class MainWindow(QMainWindow, Ui_MainWindow): # Giving some feedback if not errors: - print(self.tr("Project {} loaded.").format(project)) + LOGGER.info("Project {} loaded.".format(project)) F.statusMessage( self.tr("Project {} loaded.").format(project), 2000) else: - print(self.tr("Project {} loaded with some errors:").format(project)) + LOGGER.error("Project {} loaded with some errors:".format(project)) for e in errors: - print(self.tr(" * {} wasn't found in project file.").format(e)) + LOGGER.error(" * {} wasn't found in project file.".format(e)) F.statusMessage( self.tr("Project {} loaded with some errors.").format(project), 5000, importance = 3) @@ -881,11 +958,13 @@ class MainWindow(QMainWindow, Ui_MainWindow): # Characters self.lstCharacters.setCharactersModel(self.mdlCharacter) self.tblPersoInfos.setModel(self.mdlCharacter) - - self.btnAddPerso.clicked.connect(self.mdlCharacter.addCharacter, F.AUC) try: - self.btnRmPerso.clicked.connect(self.lstCharacters.removeCharacter, F.AUC) + self.btnAddPerso.clicked.connect(self.lstCharacters.addCharacter, F.AUC) + self.btnRmPerso.clicked.connect(self.deleteCharacter, F.AUC) + self.btnPersoColor.clicked.connect(self.lstCharacters.choseCharacterColor, F.AUC) + self.chkPersoPOV.stateChanged.connect(self.lstCharacters.changeCharacterPOVState, F.AUC) + self.btnPersoAddInfo.clicked.connect(self.lstCharacters.addCharacterInfo, F.AUC) self.btnPersoRmInfo.clicked.connect(self.lstCharacters.removeCharacterInfo, F.AUC) except TypeError: @@ -1026,7 +1105,7 @@ class MainWindow(QMainWindow, Ui_MainWindow): # disconnect only removes one connection at a time. while True: try: - if oldHandler is not None: + if oldHandler != None: signal.disconnect(oldHandler) else: signal.disconnect() @@ -1037,9 +1116,12 @@ class MainWindow(QMainWindow, Ui_MainWindow): # Break connections for UI elements that were connected in makeConnections() # Characters - self.disconnectAll(self.btnAddPerso.clicked, self.mdlCharacter.addCharacter) - self.disconnectAll(self.btnRmPerso.clicked, self.lstCharacters.removeCharacter) + self.disconnectAll(self.btnAddPerso.clicked, self.lstCharacters.addCharacter) + self.disconnectAll(self.btnRmPerso.clicked, self.deleteCharacter) + self.disconnectAll(self.btnPersoColor.clicked, self.lstCharacters.choseCharacterColor) + self.disconnectAll(self.chkPersoPOV.stateChanged, self.lstCharacters.changeCharacterPOVState) + self.disconnectAll(self.btnPersoAddInfo.clicked, self.lstCharacters.addCharacterInfo) self.disconnectAll(self.btnPersoRmInfo.clicked, self.lstCharacters.removeCharacterInfo) @@ -1090,41 +1172,66 @@ class MainWindow(QMainWindow, Ui_MainWindow): # HELP ############################################################################### + def centerChildWindow(self, win): + r = win.geometry() + r2 = self.geometry() + win.move(r2.center() - QPoint(int(r.width()/2), int(r.height()/2))) + + def support(self): + openURL("https://github.com/olivierkes/manuskript/wiki/Technical-Support") + + def locateLogFile(self): + logfile = getLogFilePath() + + # Make sure we are even logging to a file. + if not logfile: + QMessageBox(QMessageBox.Information, + self.tr("Sorry!"), + "" + + self.tr("This session is not being logged.") + + "
", + QMessageBox.Ok).exec() + return + + # Remind user that log files are at their best once they are complete. + msg = QMessageBox(QMessageBox.Information, + self.tr("A log file is a Work in Progress!"), + "" + + self.tr("The log file \"{}\" will continue to be written to until Manuskript is closed.").format(os.path.basename(logfile)) + + "
" + + "" + + self.tr("It will now be displayed in your file manager, but is of limited use until you close Manuskript.") + + "
", + QMessageBox.Ok) + + ret = msg.exec() + + # Open the filemanager. + if ret == QMessageBox.Ok: + if not showInFolder(logfile): + # If everything convenient fails, at least make sure the user can browse to its location manually. + QMessageBox(QMessageBox.Critical, + self.tr("Error!"), + "" + + self.tr("An error was encountered while trying to show the log file below in your file manager.") + + "
" + + "" + + logfile + + "
", + QMessageBox.Ok).exec() + + def about(self): self.dialog = aboutDialog(mw=self) self.dialog.setFixedSize(self.dialog.size()) self.dialog.show() # Center about dialog - r = self.dialog.geometry() - r2 = self.geometry() - self.dialog.move(r2.center() - r.center()) + self.centerChildWindow(self.dialog) ############################################################################### # GENERAL AKA UNSORTED ############################################################################### - def clickCycle(self, i): - if i == 0: # step 2 - paragraph summary - self.tabMain.setCurrentIndex(self.TabSummary) - self.tabSummary.setCurrentIndex(1) - if i == 1: # step 3 - characters summary - self.tabMain.setCurrentIndex(self.TabPersos) - self.tabPersos.setCurrentIndex(0) - if i == 2: # step 4 - page summary - self.tabMain.setCurrentIndex(self.TabSummary) - self.tabSummary.setCurrentIndex(2) - if i == 3: # step 5 - characters description - self.tabMain.setCurrentIndex(self.TabPersos) - self.tabPersos.setCurrentIndex(1) - if i == 4: # step 6 - four page synopsis - self.tabMain.setCurrentIndex(self.TabSummary) - self.tabSummary.setCurrentIndex(3) - if i == 5: # step 7 - full character charts - self.tabMain.setCurrentIndex(self.TabPersos) - self.tabPersos.setCurrentIndex(2) - if i == 6: # step 8 - scene list - self.tabMain.setCurrentIndex(self.TabPlots) - def wordCount(self, i): src = { @@ -1263,24 +1370,29 @@ class MainWindow(QMainWindow, Ui_MainWindow): self.actShowHelp.setChecked(False) # Spellcheck - if enchant: + if Spellchecker.isInstalled(): self.menuDict = QMenu(self.tr("Dictionary")) self.menuDictGroup = QActionGroup(self) self.updateMenuDict() self.menuTools.addMenu(self.menuDict) self.actSpellcheck.toggled.connect(self.toggleSpellcheck, F.AUC) - self.dictChanged.connect(self.mainEditor.setDict, F.AUC) - self.dictChanged.connect(self.redacMetadata.setDict, F.AUC) - self.dictChanged.connect(self.outlineItemEditor.setDict, F.AUC) + # self.dictChanged.connect(self.mainEditor.setDict, F.AUC) + # self.dictChanged.connect(self.redacMetadata.setDict, F.AUC) + # self.dictChanged.connect(self.outlineItemEditor.setDict, F.AUC) else: # No Spell check support self.actSpellcheck.setVisible(False) - a = QAction(self.tr("Install PyEnchant to use spellcheck"), self) - a.setIcon(self.style().standardIcon(QStyle.SP_MessageBoxWarning)) - a.triggered.connect(self.openPyEnchantWebPage, F.AUC) - self.menuTools.addAction(a) + for lib, requirement in Spellchecker.supportedLibraries().items(): + a = QAction(self.tr("Install {}{} to use spellcheck").format(lib, requirement or ""), self) + a.setIcon(self.style().standardIcon(QStyle.SP_MessageBoxWarning)) + # Need to bound the lib argument otherwise the lambda uses the same lib value across all calls + def gen_slot_cb(l): + return lambda: self.openSpellcheckWebPage(l) + a.triggered.connect(gen_slot_cb(lib), F.AUC) + self.menuTools.addAction(a) + ############################################################################### # SPELLCHECK @@ -1288,37 +1400,75 @@ class MainWindow(QMainWindow, Ui_MainWindow): def updateMenuDict(self): - if not enchant: + if not Spellchecker.isInstalled(): return self.menuDict.clear() - for i in enchant.list_dicts(): - a = QAction(str(i[0]), self) - a.setCheckable(True) - if settings.dict is None: - settings.dict = enchant.get_default_language() - if str(i[0]) == settings.dict: - a.setChecked(True) - a.triggered.connect(self.setDictionary, F.AUC) - self.menuDictGroup.addAction(a) + dictionaries = Spellchecker.availableDictionaries() + + # Set first run dictionary + if settings.dict == None: + settings.dict = Spellchecker.getDefaultDictionary() + + # Check if project dict is unavailable on this machine + dict_available = False + for lib, dicts in dictionaries.items(): + if dict_available: + break + for i in dicts: + if Spellchecker.normalizeDictName(lib, i) == settings.dict: + dict_available = True + break + # Reset dict to default one if it's unavailable + if not dict_available: + settings.dict = Spellchecker.getDefaultDictionary() + + for lib, dicts in dictionaries.items(): + if len(dicts) > 0: + a = QAction(lib, self) + else: + a = QAction(self.tr("{} has no installed dictionaries").format(lib), self) + a.setEnabled(False) self.menuDict.addAction(a) + for i in dicts: + a = QAction(i, self) + a.data = lib + a.setCheckable(True) + if Spellchecker.normalizeDictName(lib, i) == settings.dict: + a.setChecked(True) + a.triggered.connect(self.setDictionary, F.AUC) + self.menuDictGroup.addAction(a) + self.menuDict.addAction(a) + self.menuDict.addSeparator() + + # If a new dictionary was chosen, apply the change and re-enable spellcheck if it was enabled. + if not dict_available: + self.setDictionary() + self.toggleSpellcheck(settings.spellcheck) + + for lib, requirement in Spellchecker.supportedLibraries().items(): + if lib not in dictionaries: + a = QAction(self.tr("{}{} is not installed").format(lib, requirement or ""), self) + a.setEnabled(False) + self.menuDict.addAction(a) + self.menuDict.addSeparator() def setDictionary(self): - if not enchant: + if not Spellchecker.isInstalled(): return for i in self.menuDictGroup.actions(): if i.isChecked(): # self.dictChanged.emit(i.text().replace("&", "")) - settings.dict = i.text().replace("&", "") + settings.dict = Spellchecker.normalizeDictName(i.data, i.text().replace("&", "")) # Find all textEditView from self, and toggle spellcheck for w in self.findChildren(textEditView, QRegExp(".*"), Qt.FindChildrenRecursively): w.setDict(settings.dict) - def openPyEnchantWebPage(self): - F.openURL("http://pythonhosted.org/pyenchant/") + def openSpellcheckWebPage(self, lib): + F.openURL(Spellchecker.getLibraryURL(lib)) def toggleSpellcheck(self, val): settings.spellcheck = val @@ -1343,9 +1493,7 @@ class MainWindow(QMainWindow, Ui_MainWindow): self.sw.hide() self.sw.setWindowModality(Qt.ApplicationModal) self.sw.setWindowFlags(Qt.Dialog) - r = self.sw.geometry() - r2 = self.geometry() - self.sw.move(r2.center() - r.center()) + self.centerChildWindow(self.sw) if tab: self.sw.setTab(tab) self.sw.show() @@ -1357,6 +1505,7 @@ class MainWindow(QMainWindow, Ui_MainWindow): def frequencyAnalyzer(self): self.fw = frequencyAnalyzer(self) self.fw.show() + self.centerChildWindow(self.fw) def sessionTargets(self): self.td = targetsDialog(self) @@ -1406,7 +1555,7 @@ class MainWindow(QMainWindow, Ui_MainWindow): self.menuView.addMenu(self.menuMode) self.menuView.addSeparator() - # print("Generating menus with", settings.viewSettings) + # LOGGER.debug("Generating menus with %s.", settings.viewSettings) for mnu, mnud, icon in menus: m = QMenu(mnu, self.menuView) @@ -1473,7 +1622,7 @@ class MainWindow(QMainWindow, Ui_MainWindow): w.cmbPOV.setVisible(val) # POV in outline view - if val is None and Outline.POV in settings.outlineViewColumns: + if val == None and Outline.POV in settings.outlineViewColumns: settings.outlineViewColumns.remove(Outline.POV) from manuskript.ui.views.outlineView import outlineView @@ -1489,17 +1638,42 @@ class MainWindow(QMainWindow, Ui_MainWindow): ############################################################################### def doImport(self): + # Warn about buggy Qt versions and import crash + # + # (Py)Qt 5.11 and 5.12 have a bug that can cause crashes when simply + # setting up various UI elements. + # This has been reported and verified to happen with File -> Import. + # See PR #611. + if re.match("^5\\.1[12](\\.?|$)", qVersion()): + warning1 = self.tr("PyQt / Qt versions 5.11 and 5.12 are known to cause a crash which might result in a loss of data.") + warning2 = self.tr("PyQt {} and Qt {} are in use.").format(qVersion(), PYQT_VERSION_STR) + + # Don't translate for debug log. + LOGGER.warning(warning1) + LOGGER.warning(warning2) + + msg = QMessageBox(QMessageBox.Warning, + self.tr("Proceed with import at your own risk"), + "" + + warning1 + + "
" + + "" + + warning2 + + "
", + QMessageBox.Abort | QMessageBox.Ignore) + msg.setDefaultButton(QMessageBox.Abort) + + # Return because user heeds warning + if msg.exec() == QMessageBox.Abort: + return + + # Proceed with Import self.dialog = importerDialog(mw=self) self.dialog.show() + self.centerChildWindow(self.dialog) - r = self.dialog.geometry() - r2 = self.geometry() - self.dialog.move(r2.center() - r.center()) def doCompile(self): self.dialog = exporterDialog(mw=self) self.dialog.show() - - r = self.dialog.geometry() - r2 = self.geometry() - self.dialog.move(r2.center() - r.center()) + self.centerChildWindow(self.dialog) diff --git a/manuskript/models/abstractItem.py b/manuskript/models/abstractItem.py index 1bef848..333172d 100644 --- a/manuskript/models/abstractItem.py +++ b/manuskript/models/abstractItem.py @@ -9,18 +9,24 @@ from PyQt5.QtCore import Qt from PyQt5.QtGui import QIcon, QFont from PyQt5.QtWidgets import QTextEdit, qApp from lxml import etree as ET +import re from manuskript import enums +import logging +LOGGER = logging.getLogger(__name__) class abstractItem(): - # Enum kept on the class for easier acces + # Enum kept on the class for easier access enum = enums.Abstract # Used for XML export name = "abstractItem" + # Regexp from https://stackoverflow.com/questions/8733233/filtering-out-certain-bytes-in-python + valid_xml_re = re.compile(u'[^\u0020-\uD7FF\u0009\u000A\u000D\uE000-\uFFFD\U00010000-\U0010FFFF]+') + def __init__(self, model=None, title="", _type="abstract", xml=None, parent=None, ID=None): self._data = {} @@ -35,14 +41,19 @@ class abstractItem(): self._data[self.enum.title] = title self._data[self.enum.type] = _type - if xml is not None: + if xml != None: self.setFromXML(xml) + if parent: + # add this as a child to the parent, and link to the outlineModel of the parent + parent.appendChild(self) + if ID: self._data[self.enum.ID] = ID - if parent: - parent.appendChild(self) + if self._model: + self._model.updateAvailableIDs(ID) + ####################################################################### # Model @@ -50,6 +61,11 @@ class abstractItem(): def setModel(self, model): self._model = model + if not self.ID(): + self.getUniqueID() + elif model: + # if we are setting a model update it's ID + self._model.updateAvailableIDs(self.ID()) for c in self.children(): c.setModel(model) @@ -101,7 +117,7 @@ class abstractItem(): return self._data[self.enum.type] ####################################################################### - # Parent / Children managment + # Parent / Children management ####################################################################### def child(self, row): @@ -122,6 +138,7 @@ class abstractItem(): def row(self): if self.parent(): return self.parent().childItems.index(self) + return None def appendChild(self, child): self.insertChild(self.childCount(), child) @@ -130,8 +147,6 @@ class abstractItem(): self.childItems.insert(row, child) child._parent = self child.setModel(self._model) - if not child.ID(): - child.getUniqueID() def removeChild(self, row): """ @@ -140,6 +155,9 @@ class abstractItem(): @return: the removed abstractItem """ r = self.childItems.pop(row) + # Disassociate the child from its parent and the model. + r._parent = None + r.setModel(None) return r def parent(self): @@ -177,12 +195,20 @@ class abstractItem(): item.setData(self.enum.ID, None) return item + def siblings(self): + if self.parent(): + return self.parent().children() + return [] + ############################################################################### # IDS ############################################################################### def getUniqueID(self, recursive=False): - self.setData(self.enum.ID, self._model.rootItem.findUniqueID()) + if not self._model: + return + + self.setData(self.enum.ID, self._model.requestNewID()) if recursive: for c in self.children(): @@ -196,31 +222,31 @@ class abstractItem(): self.IDs = self.listAllIDs() if max([self.IDs.count(i) for i in self.IDs if i]) != 1: - print("WARNING ! There are some items with same IDs:", [i for i in self.IDs if i and self.IDs.count(i) != 1]) + LOGGER.warning("There are some items with overlapping IDs: %s", [i for i in self.IDs if i and self.IDs.count(i) != 1]) + _IDs = [self.ID()] def checkChildren(item): + "Check recursively every children and give them unique, non-empty, non-zero IDs." for c in item.children(): _id = c.ID() - if not _id or _id == "0": + if not _id or _id == "0" or _id in _IDs: c.getUniqueID() + LOGGER.warning("* Item {} '{}' is given new unique ID: '{}'".format(_id, c.title(), c.ID())) + _IDs.append(_id) checkChildren(c) checkChildren(self) + # Not sure if self.IDs is still useful (it was used in the old unique ID generating system at least). + # It might be deleted everywhere. But just in the meantime, it should at least be up to date. + self.IDs = self.listAllIDs() + def listAllIDs(self): IDs = [self.ID()] for c in self.children(): IDs.extend(c.listAllIDs()) return IDs - def findUniqueID(self): - IDs = [int(i) for i in self.IDs] - k = 1 - while k in IDs: - k += 1 - self.IDs.append(str(k)) - return str(k) - ####################################################################### # Data ####################################################################### @@ -237,6 +263,10 @@ class abstractItem(): # Setting data self._data[column] = data + # The _model will be none during splitting + if self._model and column == self.enum.ID: + self._model.updateAvailableIDs(data) + # Emit signal self.emitDataChanged(cols=[column]) # new in 0.5.0 @@ -249,6 +279,9 @@ class abstractItem(): # We want to force some data even if they're empty XMLForce = [] + def cleanTextForXML(self, text): + return self.valid_xml_re.sub('', text) + def toXML(self): """ Returns a string containing the item (and children) in XML. @@ -263,7 +296,7 @@ class abstractItem(): continue val = self.data(attrib) if val or attrib in self.XMLForce: - item.set(attrib.name, str(val)) + item.set(attrib.name, self.cleanTextForXML(str(val))) # Saving lastPath item.set("lastPath", self._lastPath) diff --git a/manuskript/models/abstractModel.py b/manuskript/models/abstractModel.py index 4240a18..16de33c 100644 --- a/manuskript/models/abstractModel.py +++ b/manuskript/models/abstractModel.py @@ -26,6 +26,9 @@ except: pass import time, os +import logging +LOGGER = logging.getLogger(__name__) + class abstractModel(QAbstractItemModel): """ @@ -37,18 +40,30 @@ class abstractModel(QAbstractItemModel): - XML Import / Export - Drag'n'drop + Row => item/abstractModel/etc. + Col => data sub-element. Col 1 (second counting) is ID for all model types. + """ def __init__(self, parent): QAbstractItemModel.__init__(self, parent) - - self.rootItem = outlineItem(self, title="Root", ID="0") + self.rootItem = None + self.nextAvailableID = 1 # Stores removed item, in order to remove them on disk when saving, depending on the file format. self.removed = [] self._removingRows = False - def index(self, row, column, parent): + def requestNewID(self): + newID = self.nextAvailableID + self.nextAvailableID += 1 + return str(newID) + # Call this if loading an ID from file rather than assigning a new one. + def updateAvailableIDs(self, addedID): + if int(addedID) >= self.nextAvailableID: + self.nextAvailableID = int(addedID) + 1 + + def index(self, row, column, parent): if not self.hasIndex(row, column, parent): return QModelIndex() @@ -57,6 +72,9 @@ class abstractModel(QAbstractItemModel): else: parentItem = parent.internalPointer() + if not parentItem: + return QModelIndex() + childItem = parentItem.child(row) if childItem: return self.createIndex(row, column, childItem) @@ -71,11 +89,9 @@ class abstractModel(QAbstractItemModel): if not parent: parent = self.rootItem - if len(parent.children()) == 0: + if (not parent) or (len(parent.children()) == 0): return None - # print(item.title(), [i.title() for i in parent.children()]) - row = parent.children().index(item) col = column return self.createIndex(row, col, item) @@ -90,23 +106,38 @@ class abstractModel(QAbstractItemModel): Returns a list of IDs of all items containing `text` in columns `columns` (being a list of int). """ + if not self.rootItem: + return list() + return self.rootItem.findItemsContaining(text, columns, mainWindow(), caseSensitive) - def getItemByID(self, ID): + def getItemByID(self, ID, ignore=None): + """Returns the item whose ID is `ID`, unless this item matches `ignore`.""" + def search(item): if item.ID() == ID: + if item == ignore: + # The item we really want won't be found in the children of this + # particular item anymore; stop searching this branch entirely. + return None return item for c in item.children(): r = search(c) if r: return r + if not self.rootItem: + return None + item = search(self.rootItem) return item - def getIndexByID(self, ID, column=0): - "Returns the index of item whose ID is `ID`. If none, returns QModelIndex()." - item = self.getItemByID(ID) + def getIndexByID(self, ID, column=0, ignore=None): + """Returns the index of item whose ID is `ID`. If none, returns QModelIndex(). + + If `ignore` is set, it will not return that item if found as valid match for the ID""" + + item = self.getItemByID(ID, ignore=ignore) if not item: return QModelIndex() else: @@ -119,7 +150,10 @@ class abstractModel(QAbstractItemModel): childItem = index.internalPointer() parentItem = childItem.parent() - if parentItem == self.rootItem: + # Check whether the parent is the root, or is otherwise invalid. + # That is to say: no parent or the parent lacks a parent. + if (parentItem == self.rootItem) or \ + (not parentItem) or (not parentItem.parent()): return QModelIndex() return self.createIndex(parentItem.row(), 0, parentItem) @@ -133,13 +167,21 @@ class abstractModel(QAbstractItemModel): else: parentItem = parent.internalPointer() + if not parentItem: + return 0 + return parentItem.childCount() def columnCount(self, parent=QModelIndex()): if parent.isValid(): - return parent.internalPointer().columnCount() + parentItem = parent.internalPointer() else: - return self.rootItem.columnCount() + parentItem = self.rootItem + + if not parentItem: + return 0 + + return parentItem.columnCount() def data(self, index, role=Qt.DisplayRole): if not index.isValid(): @@ -156,7 +198,7 @@ class abstractModel(QAbstractItemModel): # self.dataChanged.emit(index.sibling(index.row(), 0), # index.sibling(index.row(), max([i.value for i in Outline]))) - # print("Model emit", index.row(), index.column()) + # LOGGER.debug("Model dataChanged emit: %s, %s", index.row(), index.column()) self.dataChanged.emit(index, index) if index.column() == Outline.type: @@ -198,8 +240,6 @@ class abstractModel(QAbstractItemModel): else: return QVariant() - return True - def maxLevel(self): """Returns the max depth of the model.""" def depth(item, d=-1): @@ -209,6 +249,9 @@ class abstractModel(QAbstractItemModel): r = max(r, depth(c, d)) return r + if not self.rootItem: + return 0 + d = depth(self.rootItem) return d @@ -271,7 +314,7 @@ class abstractModel(QAbstractItemModel): # # Gets encoded mime data to retrieve the item items = self.decodeMimeData(data) - if items is None: + if not items: return False # We check if parent is not a child of one of the items @@ -293,6 +336,9 @@ class abstractModel(QAbstractItemModel): else: parentItem = parent.internalPointer() + if not parentItem: + return False + for item in items: # Get parentItem's parents IDs in a list path = parentItem.pathID() # path to item in the form [(ID, title), ...] @@ -309,7 +355,7 @@ class abstractModel(QAbstractItemModel): return None encodedData = bytes(data.data("application/xml")).decode() root = ET.XML(encodedData) - if root is None: + if not root: return None if root.tag != "outlineItems": @@ -369,7 +415,7 @@ class abstractModel(QAbstractItemModel): items = self.decodeMimeData(data) - if items is None: + if items == None: return False if column > 0: @@ -403,21 +449,29 @@ class abstractModel(QAbstractItemModel): # In case of copy actions, items might be duplicates, so we need new IDs. # But they might not be, if we cut, then paste. Paste is a Copy Action. # The first paste would not need new IDs. But subsequent ones will. + + # Recursively change the existing IDs to new, unique values. No need to strip out the old + # even if they are not duplicated in pasting. There is no practical need for ID conservation. + if action == Qt.CopyAction: IDs = self.rootItem.listAllIDs() for item in items: if item.ID() in IDs: - # Recursively remove ID. So will get a new one when inserted. - def stripID(item): - item.setData(Outline.ID, None) - for c in item.children(): - stripID(c) + # Items don't get new IDs, because they are not part of a model yet, + # so the following call does nothing: + # item.getUniqueID(recursive=True) - stripID(item) + # Instead we need to remove IDs (recursively) in all copied items, so that they + # will receive new ones when inserted within the model. + def removeIDs(i): + i.setData(item.enum.ID, None) + for c in i.children(): + removeIDs(c) + + removeIDs(item) r = self.insertItems(items, beginRow, parent) - return r ################# ADDING AND REMOVING ################# @@ -431,12 +485,15 @@ class abstractModel(QAbstractItemModel): else: parentItem = parent.internalPointer() + if not parentItem: + return False + if parent.isValid() and parent.column() != 0: parent = parentItem.index() # Insert only if parent is folder if parentItem.isFolder(): - self.beginInsertRows(parent, row, row + len(items) - 1) + self.beginInsertRows(parent, row, row + len(items) - 1) # Create space. for i in items: parentItem.insertChild(row + items.index(i), i) @@ -454,6 +511,9 @@ class abstractModel(QAbstractItemModel): else: parentItem = parent.internalPointer() + if not parentItem: + return + if parent.isValid() and parent.column() != 0: parent = parentItem.index() @@ -495,8 +555,12 @@ class abstractModel(QAbstractItemModel): else: parentItem = parent.internalPointer() - self._removingRows = True # Views that are updating can easily know - # if this is due to row removal. + if not parentItem: + return False + + self._removingRows = True + # Views that are updating can easily know + # if this is due to row removal. self.beginRemoveRows(parent, row, row + count - 1) for i in range(count): item = parentItem.removeChild(row) @@ -521,10 +585,15 @@ class abstractModel(QAbstractItemModel): ################# XML / saving / loading ################# def saveToXML(self, xml=None): + if not self.rootItem: + return str() + "If xml (filename) is given, saves the items to xml. Otherwise returns as string." root = ET.XML(self.rootItem.toXML()) + if xml: ET.ElementTree(root).write(xml, encoding="UTF-8", xml_declaration=True, pretty_print=True) + return str() else: return ET.tostring(root, encoding="UTF-8", xml_declaration=True, pretty_print=True) @@ -541,6 +610,10 @@ class abstractModel(QAbstractItemModel): def indexFromPath(self, path): path = path.split(",") item = self.rootItem + + if not item: + return None + for p in path: if p != "" and int(p) < item.childCount(): item = item.child(int(p)) diff --git a/manuskript/models/characterModel.py b/manuskript/models/characterModel.py index a42f8f0..feaa72d 100644 --- a/manuskript/models/characterModel.py +++ b/manuskript/models/characterModel.py @@ -3,11 +3,15 @@ from PyQt5.QtCore import QModelIndex, Qt, QAbstractItemModel, QVariant from PyQt5.QtGui import QIcon, QPixmap, QColor -from manuskript.functions import randomColor, iconColor, mainWindow -from manuskript.enums import Character as C +from manuskript.functions import randomColor, iconColor, mainWindow, search +from manuskript.enums import Character as C, Model +from manuskript.searchLabels import CharacterSearchLabels + +from manuskript.models.searchableModel import searchableModel +from manuskript.models.searchableItem import searchableItem -class characterModel(QAbstractItemModel): +class characterModel(QAbstractItemModel, searchableModel): def __init__(self, parent): QAbstractItemModel.__init__(self, parent) @@ -132,6 +136,9 @@ class characterModel(QAbstractItemModel): def importance(self, row): return self.character(row).importance() + def pov(self, row): + return self.character(row).pov() + ############################################################################### # MODEL QUERIES ############################################################################### @@ -143,28 +150,35 @@ class characterModel(QAbstractItemModel): @return: array of array of ´character´, by importance. """ r = [[], [], []] + for c in self.characters: r[2-int(c.importance())].append(c) + return r def getCharacterByID(self, ID): - if ID is not None: + if ID != None: ID = str(ID) for c in self.characters: if c.ID() == ID: return c + return None ############################################################################### # ADDING / REMOVING ############################################################################### - def addCharacter(self): + def addCharacter(self, importance=0, name=None): """ Creates a new character + @param importance: the importance level of the character @return: the character """ - c = Character(model=self, name=self.tr("New character")) + if not name: + name = self.tr("New character") + + c = Character(model=self, name=self.tr(name), importance=importance) self.beginInsertRows(QModelIndex(), len(self.characters), len(self.characters)) self.characters.append(c) self.endInsertRows() @@ -177,7 +191,8 @@ class characterModel(QAbstractItemModel): @return: nothing """ c = self.getCharacterByID(ID) - self.beginRemoveRows(QModelIndex(), self.characters.index(c), self.characters.index(c)) + self.beginRemoveRows(QModelIndex(), self.characters.index( + c), self.characters.index(c)) self.characters.remove(c) self.endRemoveRows() @@ -197,7 +212,11 @@ class characterModel(QAbstractItemModel): def addCharacterInfo(self, ID): c = self.getCharacterByID(ID) self.beginInsertRows(c.index(), len(c.infos), len(c.infos)) - c.infos.append(CharacterInfo(c, description="Description", value="Value")) + c.infos.append(CharacterInfo( + c, + description=self.tr("Description"), + value=self.tr("Value") + )) self.endInsertRows() mainWindow().updatePersoInfoView() @@ -217,26 +236,39 @@ class characterModel(QAbstractItemModel): c.infos.pop(r) self.endRemoveRows() + def searchableItems(self): + return self.characters + ############################################################################### # CHARACTER ############################################################################### -class Character(): - def __init__(self, model, name="No name"): + +class Character(searchableItem): + + def __init__(self, model, name=None, importance=0): self._model = model self.lastPath = "" - self._data = {} - self._data[C.name.value] = name + if not name: + name = self.translate("Unknown") + + self._data = {C.name.value: name} self.assignUniqueID() self.assignRandomColor() - self._data[C.importance.value] = "0" + self._data[C.importance.value] = str(importance) + self._data[C.pov.value] = "True" self.infos = [] + super().__init__(CharacterSearchLabels) + def name(self): return self._data[C.name.value] + def setName(self, value): + self._data[C.name.value] = value + def importance(self): return self._data[C.importance.value] @@ -246,6 +278,12 @@ class Character(): def index(self, column=0): return self._model.indexFromItem(self, column) + def data(self, column): + if column == "Info": + return self.infos + else: + return self._data.get(column, None) + def assignRandomColor(self): """ Assigns a random color the the character. @@ -274,6 +312,22 @@ class Character(): """ return iconColor(self.icon) + def setPOVEnabled(self, enabled): + if enabled != self.pov(): + if enabled: + self._data[C.pov.value] = 'True' + else: + self._data[C.pov.value] = 'False' + + try: + self._model.dataChanged.emit(self.index(), self.index()) + except: + # If it is the initialisation, won't be able to emit + pass + + def pov(self): + return self._data[C.pov.value] == 'True' + def assignUniqueID(self, parent=QModelIndex()): """Assigns an unused character ID.""" vals = [] @@ -292,6 +346,42 @@ class Character(): r.append((i.description, i.value)) return r + def searchTitle(self, column): + return self.name() + + def searchOccurrences(self, searchRegex, column): + results = [] + + data = self.searchData(column) + if isinstance(data, list): + for i in range(0, len(data)): + # For detailed info we will highlight the full row, so we pass the row index + # to the highlighter instead of the (startPos, endPos) of the match itself. + results += [self.wrapSearchOccurrence(column, i, 0, context) for + (startPos, endPos, context) in search(searchRegex, data[i].description)] + results += [self.wrapSearchOccurrence(column, i, 0, context) for + (startPos, endPos, context) in search(searchRegex, data[i].value)] + else: + results += super().searchOccurrences(searchRegex, column) + + return results + + def searchID(self): + return self.ID() + + def searchPath(self, column): + return [self.translate("Characters"), self.name(), self.translate(self.searchColumnLabel(column))] + + def searchData(self, column): + if column == C.infos: + return self.infos + else: + return self.data(column) + + def searchModel(self): + return Model.Character + + class CharacterInfo(): def __init__(self, character, description="", value=""): self.description = description diff --git a/manuskript/models/characterPOVModel.py b/manuskript/models/characterPOVModel.py new file mode 100644 index 0000000..03309a7 --- /dev/null +++ b/manuskript/models/characterPOVModel.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python +# --!-- coding: utf8 --!-- +from PyQt5.QtCore import QModelIndex, QSortFilterProxyModel +from manuskript.enums import Character as C + +class characterPOVModel(QSortFilterProxyModel): + + def __init__(self, sourceModel, parent=None): + QSortFilterProxyModel.__init__(self, parent) + + self.setSourceModel(sourceModel) + + if sourceModel: + sourceModel.dataChanged.connect(self.sourceDataChanged) + + def filterAcceptsRow(self, sourceRow, sourceParent): + # Although I would prefer to reuse the existing characterModel.pov() method, + # this is simpler to do, actually works and also more ideomatic Qt code. + index = self.sourceModel().index(sourceRow, C.pov.value, sourceParent) + value = self.sourceModel().data(index) + return bool(value) + + def rowToSource(self, row): + index = self.index(row, 0) + sourceIndex = self.mapToSource(index) + return sourceIndex.row() + + def sourceDataChanged(self, topLeft, bottomRight): + self.invalidateFilter() + + ############################################################################### + # CHARACTER QUERIES + ############################################################################### + + def character(self, row): + return self.sourceModel().character(self.rowToSource(row)) + + def name(self, row): + return self.sourceModel().name(self.rowToSource(row)) + + def icon(self, row): + return self.sourceModel().icon(self.rowToSource(row)) + + def ID(self, row): + return self.sourceModel().ID(self.rowToSource(row)) + + def importance(self, row): + return self.sourceModel().importance(self.rowToSource(row)) + + def pov(self, row): + return self.sourceModel().pov(self.rowToSource(row)) diff --git a/manuskript/models/flatDataModelWrapper.py b/manuskript/models/flatDataModelWrapper.py new file mode 100644 index 0000000..57ac262 --- /dev/null +++ b/manuskript/models/flatDataModelWrapper.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python +# --!-- coding: utf8 --!-- + +from manuskript.enums import FlatData, Model +from manuskript.searchLabels import FlatDataSearchLabels + +from manuskript.models.searchableModel import searchableModel +from manuskript.models.searchableItem import searchableItem + +""" +All searches are performed on models inheriting from searchableModel, but special metadata such as book summaries +are stored directly on a GUI element (QStandardItemModel). We wrap this GUI element inside this wrapper class +so it exposes the same interface for searches. +""" +class flatDataModelWrapper(searchableModel, searchableItem): + def __init__(self, qstandardItemModel): + self.qstandardItemModel = qstandardItemModel + + def searchableItems(self): + return [flatDataItemWrapper(self.qstandardItemModel)] + + +class flatDataItemWrapper(searchableItem): + def __init__(self, qstandardItemModel): + super().__init__(FlatDataSearchLabels) + self.qstandardItemModel = qstandardItemModel + + def searchModel(self): + return Model.FlatData + + def searchID(self): + return None + + def searchTitle(self, column): + return self.translate(self.searchColumnLabel(column)) + + def searchPath(self, column): + return [self.translate("Summary"), self.translate(self.searchColumnLabel(column))] + + def searchData(self, column): + return self.qstandardItemModel.item(1, self.searchDataIndex(column)).text() + + @staticmethod + def searchDataIndex(column): + columnIndices = { + FlatData.summarySituation: 0, + FlatData.summarySentence: 1, + FlatData.summaryPara: 2, + FlatData.summaryPage: 3, + FlatData.summaryFull: 4 + } + + return columnIndices[column] \ No newline at end of file diff --git a/manuskript/models/outlineItem.py b/manuskript/models/outlineItem.py index 9c6b436..99f0010 100644 --- a/manuskript/models/outlineItem.py +++ b/manuskript/models/outlineItem.py @@ -8,10 +8,13 @@ from PyQt5.QtGui import QFont, QIcon from PyQt5.QtWidgets import qApp from lxml import etree as ET from manuskript.models.abstractItem import abstractItem +from manuskript.models.searchableItem import searchableItem from manuskript import enums from manuskript import functions as F from manuskript import settings from manuskript.converters import HTML2PlainText +from manuskript.searchLabels import OutlineSearchLabels +from manuskript.enums import Outline, Model try: locale.setlocale(locale.LC_ALL, '') @@ -20,8 +23,10 @@ except: # number formatting pass +import logging +LOGGER = logging.getLogger(__name__) -class outlineItem(abstractItem): +class outlineItem(abstractItem, searchableItem): enum = enums.Outline @@ -30,6 +35,7 @@ class outlineItem(abstractItem): def __init__(self, model=None, title="", _type="folder", xml=None, parent=None, ID=None): abstractItem.__init__(self, model, title, _type, xml, parent, ID) + searchableItem.__init__(self, OutlineSearchLabels) self.defaultTextType = None if not self._data.get(self.enum.compile): @@ -65,6 +71,9 @@ class outlineItem(abstractItem): def POV(self): return self.data(self.enum.POV) + def resetPOV(self): + self.setData(self.enum.POV, None) + def status(self): return self.data(self.enum.status) @@ -80,6 +89,22 @@ class outlineItem(abstractItem): def wordCount(self): return self._data.get(self.enum.wordCount, 0) + def charCount(self): + return self._data.get(self.enum.charCount, 0) + + def __str__(self): + return "{id}: {folder}{title}{children}".format( + id=self.ID(), + folder="*" if self.isFolder() else "", + title=self.data(self.enum.title), + children="" if self.isText() else "({})".format(self.childCount()) + ) + + __repr__ = __str__ + + def charCount(self): + return self._data.get(self.enum.charCount, 0) + ####################################################################### # Data ####################################################################### @@ -94,6 +119,9 @@ class outlineItem(abstractItem): return [] else: + # Used to verify nbsp characters not getting clobbered. + #if column == E.text: + # print("GET", str(role), "-->", str([hex(ord(x)) for x in data])) return data elif role == Qt.DecorationRole and column == E.title: @@ -109,7 +137,7 @@ class outlineItem(abstractItem): elif role == Qt.FontRole: f = QFont() - if column == E.wordCount and self.isFolder(): + if (column == E.wordCount or column == E.charCount) and self.isFolder(): f.setItalic(True) elif column == E.goal and self.isFolder() and not self.data(E.setGoal): f.setItalic(True) @@ -130,12 +158,14 @@ class outlineItem(abstractItem): # Checking if we will have to recount words updateWordCount = False - if column in [E.wordCount, E.goal, E.setGoal]: + if column in [E.wordCount, E.charCount, E.goal, E.setGoal]: updateWordCount = not column in self._data or self._data[column] != data # Stuff to do before if column == E.text: self.addRevision() + # Used to verify nbsp characters not getting clobbered. + #print("SET", str(role), "-->", str([hex(ord(x)) for x in data])) # Calling base class implementation abstractItem.setData(self, column, data, role) @@ -143,7 +173,9 @@ class outlineItem(abstractItem): # Stuff to do afterwards if column == E.text: wc = F.wordCount(data) + cc = F.charCount(data, settings.countSpaces) self.setData(E.wordCount, wc) + self.setData(E.charCount, cc) if column == E.compile: # Title changes when compile changes @@ -168,13 +200,11 @@ class outlineItem(abstractItem): def removeChild(self, row): r = abstractItem.removeChild(self, row) - # Might be causing segfault when updateWordCount emits dataChanged - self.updateWordCount(emit=False) + self.updateWordCount() return r - def updateWordCount(self, emit=True): - """Update word count for item and parents. - If emit is False, no signal is emitted (sometimes cause segfault)""" + def updateWordCount(self): + """Update word count for item and parents.""" if not self.isFolder(): setGoal = F.toInt(self.data(self.enum.setGoal)) goal = F.toInt(self.data(self.enum.goal)) @@ -187,9 +217,12 @@ class outlineItem(abstractItem): else: wc = 0 + cc = 0 for c in self.children(): wc += F.toInt(c.data(self.enum.wordCount)) + cc += F.toInt(c.data(self.enum.charCount)) self._data[self.enum.wordCount] = wc + self._data[self.enum.charCount] = cc setGoal = F.toInt(self.data(self.enum.setGoal)) goal = F.toInt(self.data(self.enum.goal)) @@ -209,12 +242,12 @@ class outlineItem(abstractItem): else: self.setData(self.enum.goalPercentage, "") - if emit: - self.emitDataChanged([self.enum.goal, self.enum.setGoal, - self.enum.wordCount, self.enum.goalPercentage]) + self.emitDataChanged([self.enum.goal, self.enum.setGoal, + self.enum.wordCount, self.enum.charCount, + self.enum.goalPercentage]) if self.parent(): - self.parent().updateWordCount(emit) + self.parent().updateWordCount() def stats(self): wc = self.data(enums.Outline.wordCount) @@ -223,12 +256,12 @@ class outlineItem(abstractItem): if not wc: wc = 0 if goal: - return qApp.translate("outlineItem", "{} words / {} ({})").format( + return F.safeTranslate(qApp, "outlineItem", "{} words / {} ({})").format( locale.format_string("%d", wc, grouping=True), locale.format_string("%d", goal, grouping=True), "{}%".format(str(int(progress * 100)))) else: - return qApp.translate("outlineItem", "{} words").format( + return F.safeTranslate(qApp, "outlineItem", "{} words").format( locale.format_string("%d", wc, grouping=True)) ####################################################################### @@ -336,8 +369,7 @@ class outlineItem(abstractItem): return lst - def findItemsContaining(self, text, columns, mainWindow=F.mainWindow(), - caseSensitive=False, recursive=True): + def findItemsContaining(self, text, columns, mainWindow=F.mainWindow(), caseSensitive=False, recursive=True): """Returns a list if IDs of all subitems containing ``text`` in columns ``columns`` (being a list of int). @@ -350,19 +382,17 @@ class outlineItem(abstractItem): return lst - def itemContains(self, text, columns, mainWindow=F.mainWindow(), - caseSensitive=False): + def itemContains(self, text, columns, mainWindow=F.mainWindow(), caseSensitive=False): lst = [] text = text.lower() if not caseSensitive else text for c in columns: - if c == self.enum.POV and self.POV(): - c = mainWindow.mdlCharacter.getCharacterByID(self.POV()) - if c: - searchIn = c.name() + character = mainWindow.mdlCharacter.getCharacterByID(self.POV()) + if character: + searchIn = character.name() else: searchIn = "" - print("Character POV not found:", self.POV()) + LOGGER.error("Character POV not found: %s", self.POV()) elif c == self.enum.status: searchIn = mainWindow.mdlStatus.item(F.toInt(self.status()), 0).text() @@ -374,7 +404,6 @@ class outlineItem(abstractItem): searchIn = self.data(c) searchIn = searchIn.lower() if not caseSensitive else searchIn - if text in searchIn: if not self.ID() in lst: lst.append(self.ID()) @@ -460,6 +489,7 @@ class outlineItem(abstractItem): # We don't want to write some datas (computed) XMLExclude = [enums.Outline.wordCount, + enums.Outline.charCount, enums.Outline.goal, enums.Outline.goalPercentage, enums.Outline.revisions] @@ -473,7 +503,7 @@ class outlineItem(abstractItem): for r in rev: revItem = ET.Element("revision") revItem.set("timestamp", str(r[0])) - revItem.set("text", r[1]) + revItem.set("text", self.cleanTextForXML(r[1])) item.append(revItem) return item @@ -495,3 +525,39 @@ class outlineItem(abstractItem): for child in root: if child.tag == "revision": self.appendRevision(child.attrib["timestamp"], child.attrib["text"]) + + ####################################################################### + # Search + ####################################################################### + def searchModel(self): + return Model.Outline + + def searchID(self): + return self.data(Outline.ID) + + def searchTitle(self, column): + return self.title() + + def searchPath(self, column): + return [self.translate("Outline")] + self.path().split(' > ') + [self.translate(self.searchColumnLabel(column))] + + def searchData(self, column): + mainWindow = F.mainWindow() + + searchData = None + + if column == self.enum.POV and self.POV(): + character = mainWindow.mdlCharacter.getCharacterByID(self.POV()) + if character: + searchData = character.name() + + elif column == self.enum.status: + searchData = mainWindow.mdlStatus.item(F.toInt(self.status()), 0).text() + + elif column == self.enum.label: + searchData = mainWindow.mdlLabels.item(F.toInt(self.label()), 0).text() + + else: + searchData = self.data(column) + + return searchData diff --git a/manuskript/models/outlineModel.py b/manuskript/models/outlineModel.py index 63494e2..162ce80 100644 --- a/manuskript/models/outlineModel.py +++ b/manuskript/models/outlineModel.py @@ -2,12 +2,31 @@ # --!-- coding: utf8 --!-- from manuskript.models.abstractModel import abstractModel +from manuskript.models.searchableModel import searchableModel +from manuskript.models.outlineItem import outlineItem - -class outlineModel(abstractModel): +class outlineModel(abstractModel, searchableModel): def __init__(self, parent): abstractModel.__init__(self, parent) + self.rootItem = outlineItem(model=self, title="Root", ID="0") + def findItemsByPOV(self, POV): "Returns a list of IDs of all items whose POV is ``POV``." return self.rootItem.findItemsByPOV(POV) + + def searchableItems(self): + result = [] + + for child in self.rootItem.children(): + result += self._searchableItems(child) + + return result + + def _searchableItems(self, item): + result = [item] + + for child in item.children(): + result += self._searchableItems(child) + + return result diff --git a/manuskript/models/plotModel.py b/manuskript/models/plotModel.py index fe01bd0..36f4b56 100644 --- a/manuskript/models/plotModel.py +++ b/manuskript/models/plotModel.py @@ -8,12 +8,15 @@ from PyQt5.QtGui import QStandardItem from PyQt5.QtGui import QStandardItemModel from PyQt5.QtWidgets import QAction, QMenu -from manuskript.enums import Plot -from manuskript.enums import PlotStep +from manuskript.enums import Plot, PlotStep, Model from manuskript.functions import toInt, mainWindow +from manuskript.models.searchResultModel import searchResultModel +from manuskript.searchLabels import PlotSearchLabels, PLOT_STEP_COLUMNS_OFFSET +from manuskript.functions import search +from manuskript.models.searchableModel import searchableModel +from manuskript.models.searchableItem import searchableItem - -class plotModel(QStandardItemModel): +class plotModel(QStandardItemModel, searchableModel): def __init__(self, parent): QStandardItemModel.__init__(self, 0, 3, parent) self.setHorizontalHeaderLabels([i.name for i in Plot]) @@ -73,7 +76,7 @@ class plotModel(QStandardItemModel): if i == row: importance = self.item(i, Plot.importance).text() return importance - return "0" # Default to "Minor" + return "0" # Default to "Minor" def getSubPlotTextsByID(self, plotID, subplotRaw): """Returns a tuple (name, summary) for the subplot whose raw in the model @@ -102,12 +105,16 @@ class plotModel(QStandardItemModel): # ADDING / REMOVING ############################################################################### - def addPlot(self): - p = QStandardItem(self.tr("New plot")) + def addPlot(self, name=None): + if not name: + name = self.tr("New plot") + + p = QStandardItem(self.tr(name)) _id = QStandardItem(self.getUniqueID()) importance = QStandardItem(str(0)) self.appendRow([p, _id, importance, QStandardItem("Characters"), QStandardItem(), QStandardItem(), QStandardItem("Resolution steps")]) + return p, _id def getUniqueID(self, parent=QModelIndex()): """Returns an unused ID""" @@ -147,8 +154,8 @@ class plotModel(QStandardItemModel): def data(self, index, role=Qt.DisplayRole): if index.parent().isValid() and \ - index.parent().column() == Plot.steps and \ - index.column() == PlotStep.meta: + index.parent().column() == Plot.steps and \ + index.column() == PlotStep.meta: if role == Qt.TextAlignmentRole: return Qt.AlignRight | Qt.AlignVCenter elif role == Qt.ForegroundRole: @@ -186,7 +193,8 @@ class plotModel(QStandardItemModel): # Don't know why, if summary is in third position, then drag/drop deletes it... parentItem.appendRow([p, _id, QStandardItem(), summary]) # Select last index - self.mw.lstSubPlots.setCurrentIndex(parent.child(self.rowCount(parent) - 1, 0)) + self.mw.lstSubPlots.setCurrentIndex( + parent.child(self.rowCount(parent) - 1, 0)) def removeSubPlot(self): """ @@ -262,3 +270,118 @@ class plotModel(QStandardItemModel): mpr.mapped.connect(self.addPlotPerso) self.mw.btnAddPlotPerso.setMenu(menu) + + ####################################################################### + # Search + ####################################################################### + def searchableItems(self): + items = [] + + for i in range(self.rowCount()): + items.append(plotItemSearchWrapper(i, self.item, self.mw.mdlCharacter.getCharacterByID)) + + return items + + +class plotItemSearchWrapper(searchableItem): + def __init__(self, rowIndex, getItem, getCharacterByID): + self.rowIndex = rowIndex + self.getItem = getItem + self.getCharacterByID = getCharacterByID + super().__init__(PlotSearchLabels) + + def searchOccurrences(self, searchRegex, column): + results = [] + + plotName = self.getItem(self.rowIndex, Plot.name).text() + if column >= PLOT_STEP_COLUMNS_OFFSET: + results += self.searchInPlotSteps(self.rowIndex, plotName, column, column - PLOT_STEP_COLUMNS_OFFSET, searchRegex, False) + else: + item_name = self.getItem(self.rowIndex, Plot.name).text() + if column == Plot.characters: + charactersList = self.getItem(self.rowIndex, Plot.characters) + + for i in range(charactersList.rowCount()): + characterID = charactersList.child(i).text() + + character = self.getCharacterByID(characterID) + if character: + columnText = character.name() + + characterResults = search(searchRegex, columnText) + if len(characterResults): + # We will highlight the full character row in the plot characters list, so we + # return the row index instead of the match start and end positions. + results += [ + searchResultModel(Model.Plot, self.getItem(self.rowIndex, Plot.ID).text(), column, + self.translate(item_name), + self.searchPath(column), + [(i, 0)], context) for start, end, context in + search(searchRegex, columnText)] + else: + results += super().searchOccurrences(searchRegex, column) + if column == Plot.name: + results += self.searchInPlotSteps(self.rowIndex, plotName, Plot.name, PlotStep.name, + searchRegex, False) + elif column == Plot.summary: + results += self.searchInPlotSteps(self.rowIndex, plotName, Plot.summary, PlotStep.summary, + searchRegex, True) + + return results + + def searchModel(self): + return Model.Plot + + def searchID(self): + return self.getItem(self.rowIndex, Plot.ID).text() + + def searchTitle(self, column): + return self.getItem(self.rowIndex, Plot.name).text() + + def searchPath(self, column): + def _path(item): + path = [] + + if item.parent(): + path += _path(item.parent()) + path.append(item.text()) + + return path + + return [self.translate("Plot")] + _path(self.getItem(self.rowIndex, Plot.name)) + [self.translate(self.searchColumnLabel(column))] + + def searchData(self, column): + return self.getItem(self.rowIndex, column).text() + + def plotStepPath(self, plotName, plotStepName, column): + return [self.translate("Plot"), plotName, plotStepName, self.translate(self.searchColumnLabel(column))] + + def searchInPlotSteps(self, plotIndex, plotName, plotColumn, plotStepColumn, searchRegex, searchInsidePlotStep): + results = [] + + # Plot step info can be found in two places: the own list of plot steps (this is the case for ie. name and meta + # fields) and "inside" the plot step once it is selected in the list (as it's the case for the summary). + if searchInsidePlotStep: + # We are searching *inside* the plot step, so we return both the row index (for selecting the right plot + # step in the list), and (start, end) positions of the match inside the text field for highlighting it. + getSearchData = lambda rowIndex, start, end, context: ([(rowIndex, 0), (start, end)], context) + else: + # We are searching *in the plot step row*, so we only return the row index for selecting the right plot + # step in the list when highlighting search results. + getSearchData = lambda rowIndex, start, end, context: ([(rowIndex, 0)], context) + + item = self.getItem(plotIndex, Plot.steps) + for i in range(item.rowCount()): + if item.child(i, PlotStep.ID): + plotStepName = item.child(i, PlotStep.name).text() + plotStepText = item.child(i, plotStepColumn).text() + + # We will highlight the full plot step row in the plot steps list, so we + # return the row index instead of the match start and end positions. + results += [searchResultModel(Model.PlotStep, self.getItem(plotIndex, Plot.ID).text(), plotStepColumn, + self.translate(plotStepName), + self.plotStepPath(plotName, plotStepName, plotColumn), + *getSearchData(i, start, end, context)) for start, end, context in + search(searchRegex, plotStepText)] + + return results \ No newline at end of file diff --git a/manuskript/models/references.py b/manuskript/models/references.py index 17696c8..ea882a9 100644 --- a/manuskript/models/references.py +++ b/manuskript/models/references.py @@ -3,6 +3,9 @@ import re +import logging +LOGGER = logging.getLogger(__name__) + ############################################################################### # SHORT REFERENCES ############################################################################### @@ -16,7 +19,7 @@ from manuskript.enums import Outline from manuskript.enums import Character from manuskript.enums import Plot from manuskript.enums import PlotStep -from manuskript.functions import mainWindow, mixColors +from manuskript.functions import mainWindow, mixColors, safeTranslate from manuskript.ui import style as S @@ -84,7 +87,7 @@ def infos(ref): """ match = re.fullmatch(RegEx, ref) if not match: - return qApp.translate("references", "Not a reference: {}.").format(ref) + return safeTranslate(qApp, "references", "Not a reference: {}.").format(ref) _type = match.group(1) _ref = match.group(2) @@ -95,19 +98,19 @@ def infos(ref): idx = m.getIndexByID(_ref) if not idx.isValid(): - return qApp.translate("references", "Unknown reference: {}.").format(ref) + return safeTranslate(qApp, "references", "Unknown reference: {}.").format(ref) item = idx.internalPointer() # Titles - pathTitle = qApp.translate("references", "Path:") - statsTitle = qApp.translate("references", "Stats:") - POVTitle = qApp.translate("references", "POV:") - statusTitle = qApp.translate("references", "Status:") - labelTitle = qApp.translate("references", "Label:") - ssTitle = qApp.translate("references", "Short summary:") - lsTitle = qApp.translate("references", "Long summary:") - notesTitle = qApp.translate("references", "Notes:") + pathTitle = safeTranslate(qApp, "references", "Path:") + statsTitle = safeTranslate(qApp, "references", "Stats:") + POVTitle = safeTranslate(qApp, "references", "POV:") + statusTitle = safeTranslate(qApp, "references", "Status:") + labelTitle = safeTranslate(qApp, "references", "Label:") + ssTitle = safeTranslate(qApp, "references", "Short summary:") + lsTitle = safeTranslate(qApp, "references", "Long summary:") + notesTitle = safeTranslate(qApp, "references", "Notes:") # The POV of the scene POV = "" @@ -187,31 +190,31 @@ def infos(ref): elif _type == CharacterLetter: m = mainWindow().mdlCharacter c = m.getCharacterByID(int(_ref)) - if c is None: - return qApp.translate("references", "Unknown reference: {}.").format(ref) + if c == None: + return safeTranslate(qApp, "references", "Unknown reference: {}.").format(ref) index = c.index() name = c.name() # Titles - basicTitle = qApp.translate("references", "Basic info") - detailedTitle = qApp.translate("references", "Detailed info") - POVof = qApp.translate("references", "POV of:") + basicTitle = safeTranslate(qApp, "references", "Basic info") + detailedTitle = safeTranslate(qApp, "references", "Detailed info") + POVof = safeTranslate(qApp, "references", "POV of:") # Goto (link) - goto = qApp.translate("references", "Go to {}.") + goto = safeTranslate(qApp, "references", "Go to {}.") goto = goto.format(refToLink(ref)) # basic infos basic = [] for i in [ - (Character.motivation, qApp.translate("references", "Motivation"), False), - (Character.goal, qApp.translate("references", "Goal"), False), - (Character.conflict, qApp.translate("references", "Conflict"), False), - (Character.epiphany, qApp.translate("references", "Epiphany"), False), - (Character.summarySentence, qApp.translate("references", "Short summary"), True), - (Character.summaryPara, qApp.translate("references", "Longer summary"), True), + (Character.motivation, safeTranslate(qApp, "references", "Motivation"), False), + (Character.goal, safeTranslate(qApp, "references", "Goal"), False), + (Character.conflict, safeTranslate(qApp, "references", "Conflict"), False), + (Character.epiphany, safeTranslate(qApp, "references", "Epiphany"), False), + (Character.summarySentence, safeTranslate(qApp, "references", "Short summary"), True), + (Character.summaryPara, safeTranslate(qApp, "references", "Longer summary"), True), ]: val = m.data(index.sibling(index.row(), i[0].value)) @@ -271,16 +274,16 @@ def infos(ref): name = m.getPlotNameByID(_ref) if not index.isValid(): - return qApp.translate("references", "Unknown reference: {}.").format(ref) + return safeTranslate(qApp, "references", "Unknown reference: {}.").format(ref) # Titles - descriptionTitle = qApp.translate("references", "Description") - resultTitle = qApp.translate("references", "Result") - charactersTitle = qApp.translate("references", "Characters") - stepsTitle = qApp.translate("references", "Resolution steps") + descriptionTitle = safeTranslate(qApp, "references", "Description") + resultTitle = safeTranslate(qApp, "references", "Result") + charactersTitle = safeTranslate(qApp, "references", "Characters") + stepsTitle = safeTranslate(qApp, "references", "Resolution steps") # Goto (link) - goto = qApp.translate("references", "Go to {}.") + goto = safeTranslate(qApp, "references", "Go to {}.") goto = goto.format(refToLink(ref)) # Description @@ -350,15 +353,15 @@ def infos(ref): name = m.name(index) if not index.isValid(): - return qApp.translate("references", "Unknown reference: {}.").format(ref) + return safeTranslate(qApp, "references", "Unknown reference: {}.").format(ref) # Titles - descriptionTitle = qApp.translate("references", "Description") - passionTitle = qApp.translate("references", "Passion") - conflictTitle = qApp.translate("references", "Conflict") + descriptionTitle = safeTranslate(qApp, "references", "Description") + passionTitle = safeTranslate(qApp, "references", "Passion") + conflictTitle = safeTranslate(qApp, "references", "Conflict") # Goto (link) - goto = qApp.translate("references", "Go to {}.") + goto = safeTranslate(qApp, "references", "Go to {}.") goto = goto.format(refToLink(ref)) # Description @@ -393,7 +396,7 @@ def infos(ref): return text else: - return qApp.translate("references", "Unknown reference: {}.").format(ref) + return safeTranslate(qApp, "references", "Unknown reference: {}.").format(ref) def shortInfos(ref): @@ -492,28 +495,28 @@ def tooltip(ref): infos = shortInfos(ref) if not infos: - return qApp.translate("references", "Unknown reference: {}.").format(ref) + return safeTranslate(qApp, "references", "Unknown reference: {}.").format(ref) if infos == -1: - return qApp.translate("references", "Not a reference: {}.").format(ref) + return safeTranslate(qApp, "references", "Not a reference: {}.").format(ref) if infos["type"] == TextLetter: if infos["text_type"] == "folder": - tt = qApp.translate("references", "Folder: {}").format(infos["title"]) + tt = safeTranslate(qApp, "references", "Folder: {}").format(infos["title"]) else: - tt = qApp.translate("references", "Text: {}").format(infos["title"]) + tt = safeTranslate(qApp, "references", "Text: {}").format(infos["title"]) tt += "
{}".format(infos["path"]) return tt elif infos["type"] == CharacterLetter: - return qApp.translate("references", "Character: {}").format(infos["title"]) + return safeTranslate(qApp, "references", "Character: {}").format(infos["title"]) elif infos["type"] == PlotLetter: - return qApp.translate("references", "Plot: {}").format(infos["title"]) + return safeTranslate(qApp, "references", "Plot: {}").format(infos["title"]) elif infos["type"] == WorldLetter: - return qApp.translate("references", "World: {name}{path}").format( + return safeTranslate(qApp, "references", "World: {name}{path}").format( name=infos["title"], path=" ({})".format(infos["path"]) if infos["path"] else "") @@ -586,7 +589,7 @@ def findReferencesTo(ref, parent=None, recursive=True): return lst -def listReferences(ref, title=qApp.translate("references", "Referenced in:")): +def listReferences(ref, title=safeTranslate(qApp, "references", "Referenced in:")): oM = mainWindow().mdlOutline listRefs = "" @@ -627,7 +630,7 @@ def open(ref): mw.lstCharacters.setCurrentItem(item) return True - print("Error: Ref {} not found".format(ref)) + LOGGER.error("Character reference {} not found.".format(ref)) return False elif _type == TextLetter: @@ -639,7 +642,7 @@ def open(ref): mw.mainEditor.setCurrentModelIndex(index, newTab=True) return True else: - print("Ref not found") + LOGGER.error("Text reference {} not found.".format(ref)) return False elif _type == PlotLetter: @@ -651,7 +654,7 @@ def open(ref): mw.lstPlots.setCurrentItem(item) return True - print("Ref not found") + LOGGER.error("Plot reference {} not found.".format(ref)) return False elif _type == WorldLetter: @@ -664,8 +667,8 @@ def open(ref): mw.mdlWorld.indexFromItem(item)) return True - print("Ref not found") + LOGGER.error("World reference {} not found.".format(ref)) return False - print("Ref not implemented") + LOGGER.error("Unable to identify reference type: {}.".format(ref)) return False diff --git a/manuskript/models/searchFilter.py b/manuskript/models/searchFilter.py new file mode 100644 index 0000000..ae2096d --- /dev/null +++ b/manuskript/models/searchFilter.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python +# --!-- coding: utf8 --!-- + + +class searchFilter: + def __init__(self, label, enabled, modelColumns = None): + if not isinstance(label, str): + raise TypeError("label must be a str") + + if not isinstance(enabled, bool): + raise TypeError("enabled must be a bool") + + if modelColumns is not None and (not isinstance(modelColumns, list)): + raise TypeError("modelColumns must be a list or None") + + self._label = label + self._enabled = enabled + self._modelColumns = modelColumns + if self._modelColumns is None: + self._modelColumns = [] + + def label(self): + return self._label + + def enabled(self): + return self._enabled + + def modelColumns(self): + return self._modelColumns + + def setEnabled(self, enabled): + self._enabled = enabled diff --git a/manuskript/models/searchResultModel.py b/manuskript/models/searchResultModel.py new file mode 100644 index 0000000..07ad038 --- /dev/null +++ b/manuskript/models/searchResultModel.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python +# --!-- coding: utf8 --!-- + + +class searchResultModel(): + def __init__(self, model_type, model_id, column, title, path, pos, context): + self._type = model_type + self._id = model_id + self._column = column + self._title = title + self._path = path + self._pos = pos + self._context = context + + def type(self): + return self._type + + def id(self): + return self._id + + def column(self): + return self._column + + def title(self): + return self._title + + def path(self): + return self._path + + def pos(self): + return self._pos + + def context(self): + return self._context + + def __repr__(self): + return "(%s, %s, %s, %s, %s, %s, %s)" % (self._type, self._id, self._column, self._title, self._path, self._pos, self._context) + + def __eq__(self, other): + return self.type() == other.type() and \ + self.id() == other.id() and \ + self.column == other.column and \ + self.pos() == other.pos() and \ + self.context == other.context diff --git a/manuskript/models/searchableItem.py b/manuskript/models/searchableItem.py new file mode 100644 index 0000000..deaa687 --- /dev/null +++ b/manuskript/models/searchableItem.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python +# --!-- coding: utf8 --!-- + + +from manuskript.models.searchResultModel import searchResultModel +from manuskript.functions import search +from PyQt5.QtCore import QCoreApplication + + +class searchableItem: + + def __init__(self, searchColumnLabels): + self._searchColumnLabels = searchColumnLabels + + def searchOccurrences(self, searchRegex, column): + return [self.wrapSearchOccurrence(column, startPos, endPos, context) for (startPos, endPos, context) in search(searchRegex, self.searchData(column))] + + def wrapSearchOccurrence(self, column, startPos, endPos, context): + return searchResultModel(self.searchModel(), self.searchID(), column, self.searchTitle(column), self.searchPath(column), [(startPos, endPos)], context) + + def searchModel(self): + raise NotImplementedError + + def searchID(self): + raise NotImplementedError + + def searchTitle(self, column): + raise NotImplementedError + + def searchPath(self, column): + return [] + + def searchData(self, column): + raise NotImplementedError + + def searchColumnLabel(self, column): + return self._searchColumnLabels.get(column, "") + + def translate(self, text): + return QCoreApplication.translate("MainWindow", text) diff --git a/manuskript/models/searchableModel.py b/manuskript/models/searchableModel.py new file mode 100644 index 0000000..c7246b9 --- /dev/null +++ b/manuskript/models/searchableModel.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python +# --!-- coding: utf8 --!-- + + +class searchableModel(): + + def searchOccurrences(self, searchRegex, columns): + results = [] + for item in self.searchableItems(): + for column in columns: + results += item.searchOccurrences(searchRegex, column) + return results + + def searchableItems(self): + raise NotImplementedError diff --git a/manuskript/models/worldModel.py b/manuskript/models/worldModel.py index 61254cd..a166e2e 100644 --- a/manuskript/models/worldModel.py +++ b/manuskript/models/worldModel.py @@ -1,18 +1,20 @@ #!/usr/bin/env python # --!-- coding: utf8 --!-- -from PyQt5.QtCore import QModelIndex -from PyQt5.QtCore import QSize +from PyQt5.QtCore import QModelIndex, QSize from PyQt5.QtCore import Qt, QMimeData, QByteArray from PyQt5.QtGui import QStandardItem, QBrush, QFontMetrics from PyQt5.QtGui import QStandardItemModel, QColor from PyQt5.QtWidgets import QMenu, QAction, qApp -from manuskript.enums import World +from manuskript.enums import World, Model from manuskript.functions import mainWindow from manuskript.ui import style as S +from manuskript.models.searchableModel import searchableModel +from manuskript.models.searchableItem import searchableItem +from manuskript.searchLabels import WorldSearchLabels -class worldModel(QStandardItemModel): +class worldModel(QStandardItemModel, searchableModel): def __init__(self, parent): QStandardItemModel.__init__(self, 0, len(World), parent) self.mw = mainWindow() @@ -136,6 +138,9 @@ class worldModel(QStandardItemModel): _id = QStandardItem(self.getUniqueID()) row = [name, _id] + [QStandardItem() for i in range(2, len(World))] parent.appendRow(row) + + self.mw.treeWorld.setExpanded(self.selectedIndex(), True) + self.mw.treeWorld.setCurrentIndex(self.indexFromItem(name)) return name def getUniqueID(self): @@ -186,7 +191,7 @@ class worldModel(QStandardItemModel): for index in indexes: item = self.itemFromIndex(index) parent = item.parent() - if parent is None: + if parent == None: parent = self.invisibleRootItem() row_indexes.append((parent, item.row())) @@ -312,7 +317,7 @@ class worldModel(QStandardItemModel): i = self.addItem(d[0], parent) addItems(d[1], i) - addItems(data, None) + addItems(data, self.invisibleRootItem()) self.mw.treeWorld.expandAll() ############################################################################### @@ -353,3 +358,51 @@ class worldModel(QStandardItemModel): return QSize(0, h + 6) return QStandardItemModel.data(self, index, role) + + ####################################################################### + # Search + ####################################################################### + def searchableItems(self): + def readAll(item): + items = [WorldItemSearchWrapper(item, self.itemID(item), self.indexFromItem(item), self.data)] + + for c in self.children(item): + items += readAll(c) + + return items + + return readAll(self.invisibleRootItem()) + +class WorldItemSearchWrapper(searchableItem): + def __init__(self, item, itemID, itemIndex, getColumnData): + super().__init__(WorldSearchLabels) + self.item = item + self.itemID = itemID + self.itemIndex = itemIndex + self.getColumnData = getColumnData + + def searchModel(self): + return Model.World + + def searchID(self): + return self.itemID + + def searchTitle(self, column): + return self.item.text() + + def searchPath(self, column): + + def _path(item): + path = [] + + if item.parent(): + path += _path(item.parent()) + path.append(item.text()) + + return path + + return [self.translate("World")] + _path(self.item) + [self.translate(self.searchColumnLabel(column))] + + def searchData(self, column): + return self.getColumnData(self.itemIndex.sibling(self.itemIndex.row(), column)) + diff --git a/manuskript/searchLabels.py b/manuskript/searchLabels.py new file mode 100644 index 0000000..587e468 --- /dev/null +++ b/manuskript/searchLabels.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python +# --!-- coding: utf8 --!-- + +from manuskript.enums import Outline, Character, FlatData, World, Plot, PlotStep + +OutlineSearchLabels = { + Outline.title: "Title", + Outline.text: "Text", + Outline.summarySentence: "One sentence summary", + Outline.summaryFull: "Summary", + Outline.POV: "POV", + Outline.notes: "Notes", + Outline.status: "Status", + Outline.label: "Label" +} + +CharacterSearchLabels = { + Character.name: "Name", + Character.motivation: "Motivation", + Character.goal: "Goal", + Character.conflict: "Conflict", + Character.epiphany: "Epiphany", + Character.summarySentence: "One sentence summary", + Character.summaryPara: "One paragraph summary", + Character.summaryFull: "Summary", + Character.notes: "Notes", + Character.infos: "Detailed info" +} + +FlatDataSearchLabels = { + FlatData.summarySituation: "Situation", + FlatData.summarySentence: "One sentence summary", + FlatData.summaryPara: "One paragraph summary", + FlatData.summaryPage: "One page summary", + FlatData.summaryFull: "Full summary" +} + +WorldSearchLabels = { + World.name: "Name", + World.description: "Description", + World.passion: "Passion", + World.conflict: "Conflict" +} + +# Search menu includes one single option for both plot and plotStep models. For plotStep related fields +# (like PlotStep.meta) we add an offset so it is not confused with the Plot enum value mapping to the same integer. +PLOT_STEP_COLUMNS_OFFSET = 30 + +PlotSearchLabels = { + Plot.name: "Name", + Plot.description: "Description", + Plot.characters: "Characters", + Plot.result: "Result", + Plot.summary: "Summary", + PLOT_STEP_COLUMNS_OFFSET + PlotStep.meta: "Meta" +} diff --git a/manuskript/settings.py b/manuskript/settings.py index 985a35d..483d9ce 100644 --- a/manuskript/settings.py +++ b/manuskript/settings.py @@ -2,12 +2,14 @@ import collections import json -import pickle from PyQt5.QtWidgets import qApp from manuskript.enums import Outline +import logging +LOGGER = logging.getLogger(__name__) + # TODO: move some/all of those settings to application settings and not project settings # in order to allow a shared project between several writers @@ -34,6 +36,12 @@ viewSettings = { }, } +fullscreenSettings = { + "autohide-top": True, + "autohide-bottom": True, + "autohide-left": True, + } + # Application spellcheck = False dict = None @@ -41,6 +49,8 @@ corkSizeFactor = 100 folderView = "cork" lastTab = 0 openIndexes = [""] +progressChars = False +countSpaces = True autoSave = False autoSaveDelay = 5 autoSaveNoChanges = True @@ -79,7 +89,7 @@ textEditor = { } revisions = { - "keep": True, + "keep": False, "smartremove": True, "rules": collections.OrderedDict({ 10 * 60: 60, # One per minute for the last 10mn @@ -98,7 +108,7 @@ frequencyAnalyzer = { } viewMode = "fiction" # simple, fiction -saveToZip = True +saveToZip = False dontShowDeleteWarning = False def initDefaultValues(): @@ -117,20 +127,24 @@ def initDefaultValues(): def save(filename=None, protocol=None): global spellcheck, dict, corkSliderFactor, viewSettings, corkSizeFactor, folderView, lastTab, openIndexes, \ - autoSave, autoSaveDelay, saveOnQuit, autoSaveNoChanges, autoSaveNoChangesDelay, outlineViewColumns, \ + progressChars, autoSave, autoSaveDelay, saveOnQuit, autoSaveNoChanges, autoSaveNoChangesDelay, outlineViewColumns, \ corkBackground, corkStyle, fullScreenTheme, defaultTextType, textEditor, revisions, frequencyAnalyzer, viewMode, \ - saveToZip, dontShowDeleteWarning + saveToZip, dontShowDeleteWarning, fullscreenSettings allSettings = { "viewSettings": viewSettings, + "fullscreenSettings": fullscreenSettings, "dict": dict, "spellcheck": spellcheck, "corkSizeFactor": corkSizeFactor, "folderView": folderView, "lastTab": lastTab, "openIndexes": openIndexes, + "progressChars": progressChars, + "countSpaces": countSpaces, "autoSave":autoSave, "autoSaveDelay":autoSaveDelay, + # TODO: Settings Cleanup Task -- Rename saveOnQuit to saveOnProjectClose -- see PR #615 "saveOnQuit":saveOnQuit, "autoSaveNoChanges":autoSaveNoChanges, "autoSaveNoChangesDelay":autoSaveNoChangesDelay, @@ -151,37 +165,21 @@ def save(filename=None, protocol=None): #print("Saving:") #pp.pprint(allSettings) - if filename: - f = open(filename, "wb") - pickle.dump(allSettings, f) - else: - if protocol == 0: - # This looks stupid - # But a simple json.dumps with sort_keys will throw a TypeError - # because of unorderable types. - return json.dumps(json.loads(json.dumps(allSettings)), indent=4, sort_keys=True) - else: - return pickle.dumps(allSettings) + # This looks stupid + # But a simple json.dumps with sort_keys will throw a TypeError + # because of unorderable types. + return json.dumps(json.loads(json.dumps(allSettings)), indent=4, sort_keys=True) def load(string, fromString=False, protocol=None): - """Load settings from 'string'. 'string' is the filename of the pickle dump. - If fromString=True, string is the data of the pickle dumps.""" + """fromString=True is deprecated, it shouldn't be used.""" global allSettings - if not fromString: - try: - f = open(string, "rb") - allSettings = pickle.load(f) + if not string: + LOGGER.error("Cannot load settings.") + return - except: - print("{} doesn't exist, cannot load settings.".format(string)) - return - else: - if protocol == 0: - allSettings = json.loads(string) - else: - allSettings = pickle.loads(string) + allSettings = json.loads(string) #pp=pprint.PrettyPrinter(indent=4, compact=False) #print("Loading:") @@ -199,6 +197,10 @@ def load(string, fromString=False, protocol=None): if not name in viewSettings[cat]: viewSettings[cat][name] = default + if "fullscreenSettings" in allSettings: + global fullscreenSettings + fullscreenSettings = allSettings["fullscreenSettings"] + if "dict" in allSettings: global dict dict = allSettings["dict"] @@ -223,6 +225,14 @@ def load(string, fromString=False, protocol=None): global openIndexes openIndexes = allSettings["openIndexes"] + if "progressChars" in allSettings: + global progressChars + progressChars = allSettings["progressChars"] + + if "countSpaces" in allSettings: + global countSpaces + countSpaces = allSettings["countSpaces"] + if "autoSave" in allSettings: global autoSave autoSave = allSettings["autoSave"] diff --git a/manuskript/settingsWindow.py b/manuskript/settingsWindow.py index a8a13a4..3e3311d 100644 --- a/manuskript/settingsWindow.py +++ b/manuskript/settingsWindow.py @@ -1,6 +1,7 @@ #!/usr/bin/env python # --!-- coding: utf8 --!-- import os +import shutil from collections import OrderedDict from PyQt5.QtCore import QSize, QSettings, QRegExp, QTranslator, QObject @@ -8,7 +9,7 @@ from PyQt5.QtCore import Qt, QTimer from PyQt5.QtGui import QIntValidator, QIcon, QFont, QColor, QPixmap, QStandardItem, QPainter from PyQt5.QtGui import QStyleHints from PyQt5.QtWidgets import QStyleFactory, QWidget, QStyle, QColorDialog, QListWidgetItem, QMessageBox -from PyQt5.QtWidgets import qApp +from PyQt5.QtWidgets import qApp, QFileDialog # Spell checker support from manuskript import settings @@ -25,11 +26,6 @@ from manuskript.ui.views.textEditView import textEditView from manuskript.ui.welcome import welcome from manuskript.ui import style as S -try: - import enchant -except ImportError: - enchant = None - class settingsWindow(QWidget, Ui_Settings): def __init__(self, mainWindow): @@ -63,30 +59,44 @@ class settingsWindow(QWidget, Ui_Settings): self.lstMenu.setMaximumWidth(140) self.lstMenu.setMinimumWidth(140) + lowerKeys = [i.lower() for i in list(QStyleFactory.keys())] + # General self.cmbStyle.addItems(list(QStyleFactory.keys())) - self.cmbStyle.setCurrentIndex( - [i.lower() for i in list(QStyleFactory.keys())] - .index(qApp.style().objectName())) + + try: + self.cmbStyle.setCurrentIndex(lowerKeys.index(qApp.style().objectName())) + except ValueError: + self.cmbStyle.setCurrentIndex(0) + self.cmbStyle.currentIndexChanged[str].connect(self.setStyle) self.cmbTranslation.clear() tr = OrderedDict() tr["English"] = "" - tr["Deutsch"] = "manuskript_de.qm" - tr["Español"] = "manuskript_es.qm" - tr["Français"] = "manuskript_fr.qm" + tr["Arabic (Saudi Arabia)"] = "manuskript_ar_SA.qm" + tr["German"] = "manuskript_de.qm" + tr["English (Great Britain)"] = "manuskript_en_GB.qm" + tr["Spanish"] = "manuskript_es.qm" + tr["Persian"] = "manuskript_fa.qm" + tr["French"] = "manuskript_fr.qm" tr["Hungarian"] = "manuskript_hu.qm" tr["Indonesian"] = "manuskript_id.qm" tr["Italian"] = "manuskript_it.qm" + tr["Japanese"] = "manuskript_ja.qm" + tr["Korean"] = "manuskript_ko.qm" tr["Norwegian Bokmål"] = "manuskript_nb_NO.qm" tr["Dutch"] = "manuskript_nl.qm" tr["Polish"] = "manuskript_pl.qm" tr["Portuguese (Brazil)"] = "manuskript_pt_BR.qm" tr["Portuguese (Portugal)"] = "manuskript_pt_PT.qm" + tr["Romanian"] = "manuskript_ro.qm" tr["Russian"] = "manuskript_ru.qm" tr["Svenska"] = "manuskript_sv.qm" + tr["Turkish"] = "manuskript_tr.qm" + tr["Ukrainian"] = "manuskript_uk.qm" tr["Chinese (Simplified)"] = "manuskript_zh_CN.qm" + tr["Chinese (Traditional)"] = "manuskript_zh_HANT.qm" self.translations = tr for name in tr: @@ -106,6 +116,9 @@ class settingsWindow(QWidget, Ui_Settings): self.spnGeneralFontSize.setValue(f.pointSize()) self.spnGeneralFontSize.valueChanged.connect(self.setAppFontSize) + self.chkProgressChars.setChecked(settings.progressChars); + self.chkProgressChars.stateChanged.connect(self.charSettingsChanged) + self.txtAutoSave.setValidator(QIntValidator(0, 999, self)) self.txtAutoSaveNoChanges.setValidator(QIntValidator(0, 999, self)) self.chkAutoSave.setChecked(settings.autoSave) @@ -130,15 +143,15 @@ class settingsWindow(QWidget, Ui_Settings): self.chkRevisionsKeep.stateChanged.connect(self.revisionsSettingsChanged) self.chkRevisionRemove.setChecked(opt["smartremove"]) self.chkRevisionRemove.toggled.connect(self.revisionsSettingsChanged) - self.spnRevisions10Mn.setValue(60 / opt["rules"][10 * 60]) + self.spnRevisions10Mn.setValue(int(60 / opt["rules"][10 * 60])) self.spnRevisions10Mn.valueChanged.connect(self.revisionsSettingsChanged) - self.spnRevisionsHour.setValue(60 * 10 / opt["rules"][60 * 60]) + self.spnRevisionsHour.setValue(int(60 * 10 / opt["rules"][60 * 60])) self.spnRevisionsHour.valueChanged.connect(self.revisionsSettingsChanged) - self.spnRevisionsDay.setValue(60 * 60 / opt["rules"][60 * 60 * 24]) + self.spnRevisionsDay.setValue(int(60 * 60 / opt["rules"][60 * 60 * 24])) self.spnRevisionsDay.valueChanged.connect(self.revisionsSettingsChanged) - self.spnRevisionsMonth.setValue(60 * 60 * 24 / opt["rules"][60 * 60 * 24 * 30]) + self.spnRevisionsMonth.setValue(int(60 * 60 * 24 / opt["rules"][60 * 60 * 24 * 30])) self.spnRevisionsMonth.valueChanged.connect(self.revisionsSettingsChanged) - self.spnRevisionsEternity.setValue(60 * 60 * 24 * 7 / opt["rules"][None]) + self.spnRevisionsEternity.setValue(int(60 * 60 * 24 * 7 / opt["rules"][None])) self.spnRevisionsEternity.valueChanged.connect(self.revisionsSettingsChanged) # Views @@ -159,10 +172,12 @@ class settingsWindow(QWidget, Ui_Settings): for item, what, value in [ (self.rdoTreeItemCount, "InfoFolder", "Count"), (self.rdoTreeWC, "InfoFolder", "WC"), + (self.rdoTreeCC, "InfoFolder", "CC"), (self.rdoTreeProgress, "InfoFolder", "Progress"), (self.rdoTreeSummary, "InfoFolder", "Summary"), (self.rdoTreeNothing, "InfoFolder", "Nothing"), (self.rdoTreeTextWC, "InfoText", "WC"), + (self.rdoTreeTextCC, "InfoText", "CC"), (self.rdoTreeTextProgress, "InfoText", "Progress"), (self.rdoTreeTextSummary, "InfoText", "Summary"), (self.rdoTreeTextNothing, "InfoText", "Nothing"), @@ -175,6 +190,9 @@ class settingsWindow(QWidget, Ui_Settings): lambda v: self.lblTreeIconSize.setText("{}x{}".format(v, v))) self.sldTreeIconSize.setValue(settings.viewSettings["Tree"]["iconSize"]) + self.chkCountSpaces.setChecked(settings.countSpaces); + self.chkCountSpaces.stateChanged.connect(self.countSpacesChanged) + self.rdoCorkOldStyle.setChecked(settings.corkStyle == "old") self.rdoCorkNewStyle.setChecked(settings.corkStyle == "new") self.rdoCorkNewStyle.toggled.connect(self.setCorkStyle) @@ -333,6 +351,11 @@ class settingsWindow(QWidget, Ui_Settings): sttgs = QSettings(qApp.organizationName(), qApp.applicationName()) sttgs.setValue("appFontSize", val) + def charSettingsChanged(self): + settings.progressChars = True if self.chkProgressChars.checkState() else False + + self.mw.mainEditor.updateStats() + def saveSettingsChanged(self): if self.txtAutoSave.text() in ["", "0"]: self.txtAutoSave.setText("1") @@ -422,10 +445,12 @@ class settingsWindow(QWidget, Ui_Settings): for item, what, value in [ (self.rdoTreeItemCount, "InfoFolder", "Count"), (self.rdoTreeWC, "InfoFolder", "WC"), + (self.rdoTreeCC, "InfoFolder", "CC"), (self.rdoTreeProgress, "InfoFolder", "Progress"), (self.rdoTreeSummary, "InfoFolder", "Summary"), (self.rdoTreeNothing, "InfoFolder", "Nothing"), (self.rdoTreeTextWC, "InfoText", "WC"), + (self.rdoTreeTextCC, "InfoText", "CC"), (self.rdoTreeTextProgress, "InfoText", "Progress"), (self.rdoTreeTextSummary, "InfoText", "Summary"), (self.rdoTreeTextNothing, "InfoText", "Nothing"), @@ -440,6 +465,11 @@ class settingsWindow(QWidget, Ui_Settings): self.mw.treeRedacOutline.viewport().update() + def countSpacesChanged(self): + settings.countSpaces = True if self.chkCountSpaces.checkState() else False + + self.mw.mainEditor.updateStats() + def setCorkColor(self): color = QColor(settings.corkBackground["color"]) self.colorDialog = QColorDialog(color, self) @@ -458,12 +488,24 @@ class settingsWindow(QWidget, Ui_Settings): self.btnCorkColor.setStyleSheet("background:{};".format(settings.corkBackground["color"])) def setCorkBackground(self, i): + # Check if combobox was reset + if i == -1: + return + img = self.cmbCorkImage.itemData(i) img = os.path.basename(img) if img: settings.corkBackground["image"] = img else: - settings.corkBackground["image"] = "" + txt = self.cmbCorkImage.itemText(i) + if txt == "": + settings.corkBackground["image"] = "" + else: + img = self.addBackgroundImage() + if img: + self.populatesCmbBackgrounds(self.cmbCorkImage) + settings.corkBackground["image"] = img + self.setCorkImageDefault() # Update Cork view self.mw.mainEditor.updateCorkBackground() @@ -482,8 +524,34 @@ class settingsWindow(QWidget, Ui_Settings): px = QPixmap(os.path.join(p, l)).scaled(128, 64, Qt.KeepAspectRatio) cmb.addItem(QIcon(px), "", os.path.join(p, l)) + cmb.addItem(QIcon.fromTheme("list-add"), " ", "") cmb.setIconSize(QSize(128, 64)) + def addBackgroundImage(self): + lastDirectory = self.mw.welcome.getLastAccessedDirectory() + + """File dialog that request an existing file. For opening an image.""" + filename = QFileDialog.getOpenFileName(self, + self.tr("Open Image"), + lastDirectory, + self.tr("Image files (*.jpg; *.jpeg; *.png)"))[0] + if filename: + try: + px = QPixmap() + valid = px.load(filename) + del px + if valid: + shutil.copy(filename, writablePath("resources/backgrounds")) + return os.path.basename(filename) + else: + QMessageBox.warning(self, self.tr("Error"), + self.tr("Unable to load selected file")) + except Exception as e: + QMessageBox.warning(self, self.tr("Error"), + self.tr("Unable to add selected image:\n{}").format(str(e))) + return None + + def setCorkImageDefault(self): if settings.corkBackground["image"] != "": i = self.cmbCorkImage.findData(findBackground(settings.corkBackground["image"])) @@ -868,12 +936,26 @@ class settingsWindow(QWidget, Ui_Settings): self.timerUpdateFSPreview.start() def updateThemeBackground(self, i): - img = self.cmbCorkImage.itemData(i) + # Check if combobox was reset + if i == -1: + return + + img = self.cmbThemeBackgroundImage.itemData(i) if img: self._themeData["Background/ImageFile"] = os.path.split(img)[1] else: - self._themeData["Background/ImageFile"] = "" + txt = self.cmbThemeBackgroundImage.itemText(i) + if txt == "": + self._themeData["Background/ImageFile"] = "" + else: + img = self.addBackgroundImage() + if img: + self.populatesCmbBackgrounds(self.cmbThemeBackgroundImage) + self._themeData["Background/ImageFile"] = img + i = self.cmbThemeBackgroundImage.findData(self._themeData["Background/ImageFile"], flags=Qt.MatchContains) + if i != -1: + self.cmbThemeBackgroundImage.setCurrentIndex(i) self.updatePreview() def getThemeColor(self, key): diff --git a/manuskript/tests/__init__.py b/manuskript/tests/__init__.py index e31443c..54a0fe1 100644 --- a/manuskript/tests/__init__.py +++ b/manuskript/tests/__init__.py @@ -13,15 +13,16 @@ QApplication([]) # Create app and mainWindow from manuskript import main -app, MW = main.prepare(tests=True) +arguments = main.process_commandline([]) +app, MW = main.prepare(arguments, tests=True) # FIXME: Again, don't know why, but when closing a project and then reopening # one, we get a `TypeError: connection is not unique` in MainWindow: # self.btnAddSubPlot.clicked.connect(self.updateSubPlotView, F.AUC) # Yet the disconnectAll() function has been called. # Workaround: we remove the necessity for connection to be unique. This -# works for now, but could create issues later one when we want to tests -# those specific functionnality. Maybe it will be called several times. +# works for now, but could create issues later on when we want to test +# this specific functionality. Maybe it will be called several times? # At that moment, we will need to catch the exception in the MainWindow, # or better: understand why it happens at all, and only on some signals. from manuskript import functions as F diff --git a/manuskript/tests/conftest.py b/manuskript/tests/conftest.py index 09629e4..ff212ba 100644 --- a/manuskript/tests/conftest.py +++ b/manuskript/tests/conftest.py @@ -12,7 +12,7 @@ def MW(): """ from manuskript import functions as F MW = F.mainWindow() - assert MW is not None + assert MW != None assert MW == F.MW return MW @@ -23,7 +23,7 @@ def MWNoProject(MW): Take the MainWindow and close andy possibly open project. """ MW.closeProject() - assert MW.currentProject is None + assert MW.currentProject == None return MW @pytest.fixture @@ -35,9 +35,9 @@ def MWEmptyProject(MW): tf = tempfile.NamedTemporaryFile(suffix=".msk") MW.closeProject() - assert MW.currentProject is None + assert MW.currentProject == None MW.welcome.createFile(tf.name, overwrite=True) - assert MW.currentProject is not None + assert MW.currentProject != None return MW # If using with: @pytest.fixture(scope='session', autouse=True) @@ -67,6 +67,6 @@ def MWSampleProject(MW): shutil.copyfile(src, tf.name) shutil.copytree(src[:-4], tf.name[:-4]) MW.loadProject(tf.name) - assert MW.currentProject is not None + assert MW.currentProject != None return MW diff --git a/manuskript/tests/load_save/__init__.py b/manuskript/tests/load_save/__init__.py new file mode 100644 index 0000000..eb4e6c0 --- /dev/null +++ b/manuskript/tests/load_save/__init__.py @@ -0,0 +1,4 @@ +#!/usr/bin/env python +# --!-- coding: utf8 --!-- + +"""Tests for load_save module.""" diff --git a/manuskript/tests/load_save/test_ParseMMDFile.py b/manuskript/tests/load_save/test_ParseMMDFile.py new file mode 100644 index 0000000..0b9c7f3 --- /dev/null +++ b/manuskript/tests/load_save/test_ParseMMDFile.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python +# --!-- coding: utf8 --!-- + +"""Test ParseMMDFile function.""" + +from collections import OrderedDict +from manuskript.load_save.version_1 import parseMMDFile + + +BASE = "title: TheExampleNovel\n" +BASE += "ID: 42\n" +BASE += "type: folder\n" +TEXT = BASE + '\n' +TEXT_WITH_NONE_AT_START = "None: hello\n" + TEXT +TEXT_WITH_NONE_AT_END = BASE + "None: hello\n\n" +TEXT_WITH_HANGING_SPACE = BASE + " " +CONTENT = "Once upon a time, there was a dog" +TEXT_WITH_CONTENT = TEXT + CONTENT + + +def test_empty_string(): + """An empty string given to parseMMDFile.""" + result = parseMMDFile("") + assert result == ([], '') + + +def test_text(): + """A result as a list of tuples""" + result = parseMMDFile(TEXT) + assert result == ([ + ('title', 'TheExampleNovel'), + ('ID', '42'), + ('type', 'folder') + ], '') + + +def test_text_asdict(): + """A result as an OrderedDict.""" + result = parseMMDFile(TEXT, True) + assert result == (OrderedDict([ + ('title', 'TheExampleNovel'), + ('ID', '42'), + ('type', 'folder')] + ), '') + + +def test_text_with_none_at_start(): + """If the description is None, replace with an empty string.""" + result = parseMMDFile(TEXT_WITH_NONE_AT_START) + assert result == ([ + ('', 'hello'), + ('title', 'TheExampleNovel'), + ('ID', '42'), + ('type', 'folder') + ], '') + + +def test_text_wth_none_at_end(): + """If the last description is None, replace with an empty string.""" + result = parseMMDFile(TEXT_WITH_NONE_AT_END) + assert result == ([ + ('title', 'TheExampleNovel'), + ('ID', '42'), + ('type', 'folder'), + ('', 'hello') + ], '') + + +def test_text_hanging_space(): + """Hanging space invalidates the line.""" + result = parseMMDFile(TEXT_WITH_HANGING_SPACE) + assert result == ([ + ('title', 'TheExampleNovel'), + ('ID', '42') + ], '') + + +def test_text_with_content(): + """The scene has text.""" + result = parseMMDFile(TEXT_WITH_CONTENT) + assert result[1] == CONTENT diff --git a/manuskript/tests/models/test_outlineItem.py b/manuskript/tests/models/test_outlineItem.py index 05a22a7..e217309 100644 --- a/manuskript/tests/models/test_outlineItem.py +++ b/manuskript/tests/models/test_outlineItem.py @@ -59,9 +59,6 @@ def test_outlineItemsProperties(outlineItemFolder, outlineItemText): text.setData(text.enum.goal, 4) assert text.data(text.enum.goalPercentage) == .5 - # revisions - assert text.data(text.enum.revisions) == [] - def test_modelStuff(outlineModelBasic): """ Tests with children items. @@ -126,26 +123,16 @@ def test_modelStuff(outlineModelBasic): assert folder.findItemsContaining("VALUE", cols, MW, True) == [] assert folder.findItemsContaining("VALUE", cols, MW, False) == [text2.ID()] - # Revisions - text2.clearAllRevisions() - assert text2.revisions() == [] - text2.setData(text2.enum.text, "Some value.") - assert len(text2.revisions()) == 1 - text2.setData(text2.enum.text, "Some new value.") - assert len(text2.revisions()) == 1 # Auto clean - text2.deleteRevision(text2.revisions()[0][0]) - assert len(text2.revisions()) == 0 - - # Model, count and copy + # Model, count and copy k = folder._model - folder.setModel(14) - assert text2._model == 14 + folder.setModel(None) + assert text2._model is None folder.setModel(k) assert folder.columnCount() == len(folder.enum) text1 = text2.copy() - assert text1.ID() is None + assert text1.ID() == None folder.appendChild(text1) - assert text1.ID() is not None + assert text1.ID() != None assert folder.childCountRecursive() == 2 assert text1.path() == "Folder > Text" assert len(text1.pathID()) == 2 diff --git a/manuskript/tests/models/test_references.py b/manuskript/tests/models/test_references.py index c49e5a5..b9fb2d0 100644 --- a/manuskript/tests/models/test_references.py +++ b/manuskript/tests/models/test_references.py @@ -39,7 +39,7 @@ def test_references(MWSampleProject): assert "\n" in Ref.infos(Ref.plotReference(plotID)) assert "Not a ref" in Ref.infos("") assert "Unknown" in Ref.infos(Ref.plotReference("999")) - assert Ref.shortInfos(Ref.plotReference(plotID)) is not None + assert Ref.shortInfos(Ref.plotReference(plotID)) != None assert Ref.shortInfos(Ref.plotReference("999")) == None assert Ref.shortInfos(" ") == -1 @@ -50,7 +50,7 @@ def test_references(MWSampleProject): charID = IDs[0] assert "\n" in Ref.infos(Ref.characterReference(charID)) assert "Unknown" in Ref.infos(Ref.characterReference("999")) - assert Ref.shortInfos(Ref.characterReference(charID)) is not None + assert Ref.shortInfos(Ref.characterReference(charID)) != None assert Ref.shortInfos(Ref.characterReference("999")) == None assert Ref.shortInfos(" ") == -1 @@ -62,7 +62,7 @@ def test_references(MWSampleProject): assert "\n" in Ref.infos(Ref.textReference(textID)) assert "Unknown" in Ref.infos(Ref.textReference("999")) - assert Ref.shortInfos(Ref.textReference(textID)) is not None + assert Ref.shortInfos(Ref.textReference(textID)) != None assert Ref.shortInfos(Ref.textReference("999")) == None assert Ref.shortInfos(" ") == -1 @@ -73,7 +73,7 @@ def test_references(MWSampleProject): assert "\n" in Ref.infos(Ref.worldReference(worldID)) assert "Unknown" in Ref.infos(Ref.worldReference("999")) - assert Ref.shortInfos(Ref.worldReference(worldID)) is not None + assert Ref.shortInfos(Ref.worldReference(worldID)) != None assert Ref.shortInfos(Ref.worldReference("999")) == None assert Ref.shortInfos(" ") == -1 @@ -84,9 +84,9 @@ def test_references(MWSampleProject): # Titles for ref in refs: - assert Ref.title(ref) is not None - assert Ref.title(" ") is None - assert Ref.title(Ref.plotReference("999")) is None + assert Ref.title(ref) != None + assert Ref.title(" ") == None + assert Ref.title(Ref.plotReference("999")) == None # Other stuff assert Ref.type(Ref.plotReference(plotID)) == Ref.PlotLetter @@ -94,10 +94,10 @@ def test_references(MWSampleProject): assert "Unknown" in Ref.tooltip(Ref.worldReference("999")) assert "Not a ref" in Ref.tooltip(" ") for ref in refs: - assert Ref.tooltip(ref) is not None + assert Ref.tooltip(ref) != None # Links - assert Ref.refToLink(" ") is None + assert Ref.refToLink(" ") == None assert Ref.refToLink(Ref.plotReference("999")) == Ref.plotReference("999") assert Ref.refToLink(Ref.characterReference("999")) == Ref.characterReference("999") assert Ref.refToLink(Ref.textReference("999")) == Ref.textReference("999") @@ -106,7 +106,7 @@ def test_references(MWSampleProject): assert "") is None + assert Ref.open(" ") == None assert Ref.open(Ref.plotReference("999")) == False assert Ref.open(Ref.characterReference("999")) == False assert Ref.open(Ref.textReference("999")) == False diff --git a/manuskript/tests/models/test_searchFilter.py b/manuskript/tests/models/test_searchFilter.py new file mode 100644 index 0000000..8c484d0 --- /dev/null +++ b/manuskript/tests/models/test_searchFilter.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python +# --!-- coding: utf8 --!-- + +import pytest +from manuskript.models.searchFilter import searchFilter + + +def test_searchFilter_constructionOk(): + filter = searchFilter("label", True, [3]) + assert filter.label() == "label" + assert filter.enabled() is True + assert filter.modelColumns() == [3] + + +def test_searchFilter_constructionOkWithNoneModelColumn(): + filter = searchFilter("label", True) + assert filter.label() == "label" + assert filter.enabled() is True + assert filter.modelColumns() == [] + + +def test_searchFilter_constructionBadLabelType(): + with pytest.raises(TypeError, match=r".*label must be a str.*"): + searchFilter(13, True, [3]) + + +def test_searchFilter_constructionBadEnabledType(): + with pytest.raises(TypeError, match=r".*enabled must be a bool.*"): + searchFilter("label", 3, [3]) + + +def test_searchFilter_constructionBadModelColumnType(): + with pytest.raises(TypeError, match=r".*modelColumns must be a list or None.*"): + searchFilter("label", False, True) + + +def test_searchFilter_setEnabled(): + filter = searchFilter("label", True, [3]) + assert filter.enabled() is True + filter.setEnabled(False) + assert filter.enabled() is False diff --git a/manuskript/tests/models/test_searchResultModel.py b/manuskript/tests/models/test_searchResultModel.py new file mode 100644 index 0000000..62d71ca --- /dev/null +++ b/manuskript/tests/models/test_searchResultModel.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python +# --!-- coding: utf8 --!-- + +from manuskript.models.searchResultModel import searchResultModel +from manuskript.enums import Character + + +def test_searchResultModel_constructionOk(): + searchResult = searchResultModel("Character", "3", Character.notes, "Lucas", "A > B > C", (15, 18), "This is Lucas") + assert searchResult.id() == "3" + assert searchResult.column() == Character.notes + assert searchResult.title() == "Lucas" + assert searchResult.path() == "A > B > C" + assert searchResult.pos() == (15, 18) + assert searchResult.context() == "This is Lucas" + diff --git a/manuskript/tests/test_functions.py b/manuskript/tests/test_functions.py index fbea5ab..fc8dffc 100644 --- a/manuskript/tests/test_functions.py +++ b/manuskript/tests/test_functions.py @@ -3,6 +3,7 @@ """Tests for functions""" +import re from manuskript import functions as F def test_wordCount(): @@ -46,8 +47,8 @@ def test_several(): assert F.iconColor(icon).name().lower() == "#ff0000" # themeIcon - assert F.themeIcon("text") is not None - assert F.themeIcon("nonexistingname") is not None + assert F.themeIcon("text") != None + assert F.themeIcon("nonexistingname") != None # randomColor c1 = F.randomColor() @@ -75,10 +76,10 @@ def test_outlineItemColors(): def test_paths(): - assert F.appPath() is not None - assert F.writablePath is not None + assert F.appPath() != None + assert F.writablePath != None assert len(F.allPaths("suffix")) == 2 - assert F.tempFile("yop") is not None + assert F.tempFile("yop") != None f = F.findBackground("spacedreams.jpg") assert "resources/backgrounds/spacedreams.jpg" in f assert len(F.customIcons()) > 1 @@ -87,10 +88,59 @@ def test_mainWindow(): from PyQt5.QtWidgets import QWidget, QLCDNumber - assert F.mainWindow() is not None - assert F.MW is not None + assert F.mainWindow() != None + assert F.MW != None F.statusMessage("Test") F.printObjects() assert len(F.findWidgetsOfClass(QWidget)) > 0 assert len(F.findWidgetsOfClass(QLCDNumber)) == 0 + + +def test_search_noMatch(): + assert F.search(re.compile("text"), "foo") == [] + + +def test_search_singleLine_fullMatch(): + assert F.search(re.compile("text"), "text") == [(0, 4, "text")] + + +def test_search_singleLine_start(): + assert F.search(re.compile("text"), "text is this") == [(0, 4, "text is this")] + + +def test_search_singleLine_end(): + assert F.search(re.compile("text"), "This is text") == [(8, 12, "This is text")] + + +def test_search_multipleLines_fullMatch(): + assert F.search(re.compile("text"), "This is\ntext\nOK") == [(8, 12, "[...] text [...]")] + + +def test_search_multipleLines_start(): + assert F.search(re.compile("text"), "This is\ntext oh yeah\nOK") == [(8, 12, "[...] text oh yeah [...]")] + + +def test_search_multipleLines_end(): + assert F.search(re.compile("text"), "This is\nsome text\nOK") == [(13, 17, "[...] some text [...]")] + +def test_search_multipleLines_full(): + assert F.search(re.compile("text"), "This is\ntext\nOK") == [(8, 12, "[...] text [...]")] + + +def test_search_multiple_strMatches(): + assert F.search(re.compile("text"), "text, text and more text") == [ + (0, 4, "text, text and more text"), + (6, 10, "text, text and more text"), + (20, 24, "text, text and more text") + ] + + +def test_search_multiple_strMatches_caseSensitive(): + assert F.search(re.compile("text"), "TeXt, TEXT and more text") == [(20, 24, "TeXt, TEXT and more text")] + + assert F.search(re.compile("text", re.IGNORECASE), "TeXt, TEXT and more text") == [ + (0, 4, "TeXt, TEXT and more text"), + (6, 10, "TeXt, TEXT and more text"), + (20, 24, "TeXt, TEXT and more text") + ] \ No newline at end of file diff --git a/manuskript/tests/test_settingsWindow.py b/manuskript/tests/test_settingsWindow.py index 8edc6f3..71ed09f 100644 --- a/manuskript/tests/test_settingsWindow.py +++ b/manuskript/tests/test_settingsWindow.py @@ -55,7 +55,7 @@ def test_general(MWSampleProject): state = settings() assert chk.isChecked() == state chk.setChecked(not state) - assert chk.isChecked() is not state + assert chk.isChecked() != state # Loading and Saving SW.txtAutoSave.setText("0") @@ -86,7 +86,7 @@ def test_general(MWSampleProject): SW.chkOutlineTitle.setChecked(Qt.Unchecked) SW.chkOutlineTitle.setChecked(Qt.Checked) # Can't test because of the dialog - # assert SW.setCorkColor() is None + # assert SW.setCorkColor() == None SW.sldTreeIconSize.setValue(SW.sldTreeIconSize.value() + 1) SW.rdoCorkNewStyle.toggled.emit(True) SW.cmbCorkImage.currentIndexChanged.emit(0) @@ -98,7 +98,7 @@ def test_general(MWSampleProject): # Test editor switchCheckBoxAndAssert(SW.chkEditorBackgroundTransparent, lambda: S.textEditor["backgroundTransparent"]) - assert SW.restoreEditorColors() is None + assert SW.restoreEditorColors() == None switchCheckBoxAndAssert(SW.chkEditorNoBlinking, lambda: S.textEditor["cursorNotBlinking"]) # Twice on purpose: set and restore @@ -108,7 +108,7 @@ def test_general(MWSampleProject): SW.updateAllWidgets() # Labels - assert SW.updateLabelColor(MW.mdlLabels.item(1).index()) is None + assert SW.updateLabelColor(MW.mdlLabels.item(1).index()) == None rc = MW.mdlLabels.rowCount() SW.addLabel() SW.lstLabels.setCurrentIndex( @@ -150,7 +150,7 @@ def test_general(MWSampleProject): for i in range(4): SW.updateLineSpacing(i) SW.updateUIFromTheme() # No time to wait on timer - assert SW._editingTheme is not None + assert SW._editingTheme != None SW.resize(SW.geometry().size()) # resizeEvent #TODO: other edit test (see SW.loadTheme SW.saveTheme() diff --git a/manuskript/tests/ui/test_searchMenu.py b/manuskript/tests/ui/test_searchMenu.py new file mode 100644 index 0000000..659c471 --- /dev/null +++ b/manuskript/tests/ui/test_searchMenu.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python +# --!-- coding: utf8 --!-- + +from manuskript.ui.searchMenu import searchMenu +from manuskript.enums import Outline, Character, FlatData, World, Plot, PlotStep, Model +from manuskript.searchLabels import PLOT_STEP_COLUMNS_OFFSET + + +def triggerFilter(filterKey, actions): + list(filter(lambda action: action.data() == filterKey, actions))[0].trigger() + + +def test_searchMenu_defaultColumns(): + """ + By default all model columns are selected. + """ + search_menu = searchMenu() + + assert set(search_menu.columns(Model.Outline)) == { + Outline.title, Outline.text, Outline.summaryFull, + Outline.summarySentence, Outline.notes, Outline.POV, + Outline.status, Outline.label + } + + assert set(search_menu.columns(Model.Character)) == { + Character.name, Character.motivation, Character.goal, Character.conflict, + Character.epiphany, Character.summarySentence, Character.summaryPara, + Character.summaryFull, Character.notes, Character.infos + } + + assert set(search_menu.columns(Model.FlatData)) == { + FlatData.summarySituation, FlatData.summarySentence, FlatData.summaryPara, + FlatData.summaryPage, FlatData.summaryFull + } + + assert set(search_menu.columns(Model.World)) == { + World.name, World.description, World.passion, World.conflict + } + + assert set(search_menu.columns(Model.Plot)) == { + Plot.name, Plot.description, Plot.characters, Plot.result, + Plot.summary, PLOT_STEP_COLUMNS_OFFSET + PlotStep.meta + } + + +def test_searchMenu_someColumns(): + """ + When deselecting some filters the columns associated to those filters are not returned. + """ + search_menu = searchMenu() + + triggerFilter(Model.Outline, search_menu.actions()) + triggerFilter(Model.Character, search_menu.actions()) + + assert set(search_menu.columns(Model.Outline)) == set() + assert set(search_menu.columns(Model.Character)) == set() diff --git a/manuskript/ui/about.py b/manuskript/ui/about.py index a0b17f0..d7102ae 100644 --- a/manuskript/ui/about.py +++ b/manuskript/ui/about.py @@ -30,7 +30,7 @@ class aboutDialog(QWidget, Ui_about): + " "*5 + """ http://www.theologeek.ch/manuskript/
""" - + " "*5 + "Copyright © 2015-2018 Olivier Keshavjee
" + + " "*5 + "Copyright © 2015-2020 Olivier Keshavjee
" + " "*5 + """ GNU General Public License Version 3
""" diff --git a/manuskript/ui/about_ui.py b/manuskript/ui/about_ui.py index e9b46e9..72abe34 100644 --- a/manuskript/ui/about_ui.py +++ b/manuskript/ui/about_ui.py @@ -2,20 +2,22 @@ # Form implementation generated from reading ui file 'manuskript/ui/about_ui.ui' # -# Created: Wed Oct 11 08:28:24 2017 -# by: PyQt5 UI code generator 5.2.1 +# Created by: PyQt5 UI code generator 5.15.6 # -# WARNING! All changes made in this file will be lost! +# WARNING: Any manual changes made to this file will be lost when pyuic5 is +# run again. Do not edit this file unless you know what you are doing. + from PyQt5 import QtCore, QtGui, QtWidgets + class Ui_about(object): def setupUi(self, about): about.setObjectName("about") about.setWindowModality(QtCore.Qt.ApplicationModal) about.resize(445, 370) icon = QtGui.QIcon() - icon.addPixmap(QtGui.QPixmap("../../icons/Manuskript/icon-64px.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off) + icon.addPixmap(QtGui.QPixmap("manuskript/ui/../../icons/Manuskript/icon-64px.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off) about.setWindowIcon(icon) self.gridLayout = QtWidgets.QGridLayout(about) self.gridLayout.setObjectName("gridLayout") @@ -28,7 +30,7 @@ class Ui_about(object): self.gridLayout.addItem(spacerItem, 5, 0, 1, 1) self.labelLogo = QtWidgets.QLabel(about) self.labelLogo.setText("") - self.labelLogo.setPixmap(QtGui.QPixmap("../../icons/Manuskript/logo-400x104.png")) + self.labelLogo.setPixmap(QtGui.QPixmap("manuskript/ui/../../icons/Manuskript/logo-400x104.png")) self.labelLogo.setObjectName("labelLogo") self.gridLayout.addWidget(self.labelLogo, 0, 0, 1, 2) self.labelSoftwareVersion = QtWidgets.QLabel(about) @@ -44,11 +46,10 @@ class Ui_about(object): self.gridLayout.addWidget(self.buttonBox, 7, 0, 1, 1) self.retranslateUi(about) - self.buttonBox.accepted.connect(about.accept) + self.buttonBox.accepted.connect(about.accept) # type: ignore QtCore.QMetaObject.connectSlotsByName(about) def retranslateUi(self, about): _translate = QtCore.QCoreApplication.translate about.setWindowTitle(_translate("about", "About Manuskript")) self.labelLogo.setToolTip(_translate("about", "Manuskript")) - diff --git a/manuskript/ui/collapsibleDockWidgets.py b/manuskript/ui/collapsibleDockWidgets.py index 08c7331..5d2f18a 100644 --- a/manuskript/ui/collapsibleDockWidgets.py +++ b/manuskript/ui/collapsibleDockWidgets.py @@ -96,7 +96,7 @@ class collapsibleDockWidgets(QToolBar): def setCurrentGroup(self, group): self.currentGroup = group for btn, action, widget, grp in self.otherWidgets: - if not grp == group or grp is None: + if not grp == group or grp == None: action.setVisible(False) else: action.setVisible(True) diff --git a/manuskript/ui/collapsibleGroupBox.py b/manuskript/ui/collapsibleGroupBox.py index 2b3cc04..e6c1b44 100644 --- a/manuskript/ui/collapsibleGroupBox.py +++ b/manuskript/ui/collapsibleGroupBox.py @@ -6,6 +6,8 @@ from PyQt5.QtWidgets import QSizePolicy, QGroupBox, QWidget, QStylePainter, QSty QStyle, QStyleOptionFrame, QStyleOptionFocusRect from manuskript.ui import style as S +import logging +LOGGER = logging.getLogger(__name__) class collapsibleGroupBox(QGroupBox): def __init__(self, parent=None): @@ -25,7 +27,7 @@ class collapsibleGroupBox(QGroupBox): self.tempWidget.setLayout(self.layout()) # Set empty layout l = QVBoxLayout() - # print(l.contentsMargins().left(), l.contentsMargins().bottom(), l.contentsMargins().top(), ) + # LOGGER.debug("Bounds: %s, %s, %s, %s", l.contentsMargins().left(), l.contentsMargins().bottom(), l.contentsMargins().top(), l.contentsMargins().right()) l.setContentsMargins(0, 0, 0, 0) self.setLayout(l) self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Maximum) diff --git a/manuskript/ui/editors/MDFunctions.py b/manuskript/ui/editors/MDFunctions.py index 0fbd8b8..058de34 100644 --- a/manuskript/ui/editors/MDFunctions.py +++ b/manuskript/ui/editors/MDFunctions.py @@ -6,6 +6,8 @@ import re from PyQt5.QtCore import QRegExp from PyQt5.QtGui import QTextCursor +import logging +LOGGER = logging.getLogger(__name__) def MDFormatSelection(editor, style): """ @@ -15,5 +17,5 @@ def MDFormatSelection(editor, style): 1: italic 2: code """ - print("Formatting:", style, " (Unimplemented yet !)") + LOGGER.error("Formatting: %s (Not implemented!)", style) # FIXME \ No newline at end of file diff --git a/manuskript/ui/editors/blockUserData.py b/manuskript/ui/editors/blockUserData.py index 7f39c2c..44340f5 100644 --- a/manuskript/ui/editors/blockUserData.py +++ b/manuskript/ui/editors/blockUserData.py @@ -8,7 +8,7 @@ class blockUserData(QTextBlockUserData): def getUserData(block): """Returns userData if it exists, or a blank one.""" data = block.userData() - if data is None: + if data == None: data = blockUserData() return data diff --git a/manuskript/ui/editors/completer.py b/manuskript/ui/editors/completer.py index 0840b9e..ca7168c 100644 --- a/manuskript/ui/editors/completer.py +++ b/manuskript/ui/editors/completer.py @@ -55,8 +55,11 @@ class completer(QWidget, Ui_completer): self.text.setFocus(Qt.PopupFocusReason) def submit(self): - i = self.list.currentItem() - self.activated.emit(i.data(Qt.UserRole)) + item = self.list.currentItem() + + if item: + self.activated.emit(item.data(Qt.UserRole)) + self.hide() def keyPressEvent(self, event): diff --git a/manuskript/ui/editors/completer_ui.py b/manuskript/ui/editors/completer_ui.py index 093ec83..3e5c962 100644 --- a/manuskript/ui/editors/completer_ui.py +++ b/manuskript/ui/editors/completer_ui.py @@ -2,19 +2,22 @@ # Form implementation generated from reading ui file 'manuskript/ui/editors/completer_ui.ui' # -# Created by: PyQt5 UI code generator 5.4.1 +# Created by: PyQt5 UI code generator 5.15.6 # -# WARNING! All changes made in this file will be lost! +# WARNING: Any manual changes made to this file will be lost when pyuic5 is +# run again. Do not edit this file unless you know what you are doing. + + +from PyQt5 import QtCore, QtGui, QtWidgets -from PyQt5 import QtCore, QtWidgets class Ui_completer(object): def setupUi(self, completer): completer.setObjectName("completer") completer.resize(163, 143) self.verticalLayout = QtWidgets.QVBoxLayout(completer) - self.verticalLayout.setSpacing(0) self.verticalLayout.setContentsMargins(0, 0, 0, 0) + self.verticalLayout.setSpacing(0) self.verticalLayout.setObjectName("verticalLayout") self.text = QtWidgets.QLineEdit(completer) self.text.setObjectName("text") @@ -29,4 +32,3 @@ class Ui_completer(object): def retranslateUi(self, completer): _translate = QtCore.QCoreApplication.translate completer.setWindowTitle(_translate("completer", "Form")) - diff --git a/manuskript/ui/editors/editorWidget.py b/manuskript/ui/editors/editorWidget.py index bd5a165..ed2cf75 100644 --- a/manuskript/ui/editors/editorWidget.py +++ b/manuskript/ui/editors/editorWidget.py @@ -54,7 +54,7 @@ class editorWidget(QWidget, Ui_editorWidget_ui): self.dictChanged.connect(self.txtRedacText.setDict, AUC) self.txtRedacText.setHighlighting(True) self.currentDict = "" - self.spellcheck = True + self.spellcheck = settings.spellcheck self.folderView = "cork" self.mw = mainWindow() self._tabWidget = None # set by mainEditor on creation @@ -73,7 +73,7 @@ class editorWidget(QWidget, Ui_editorWidget_ui): def resizeEvent(self, event): """ textEdit's scrollBar has been reparented to self. So we need to - update it's geomtry when self is resized, and put it where we want it + update it's geometry when self is resized, and put it where we want it to be. """ # Update scrollbar geometry @@ -293,16 +293,14 @@ class editorWidget(QWidget, Ui_editorWidget_ui): try: self._model.dataChanged.connect(self.modelDataChanged, AUC) - self._model.rowsInserted.connect(self.updateIndexFromID, AUC) - self._model.rowsRemoved.connect(self.updateIndexFromID, AUC) - #self.mw.mdlOutline.rowsAboutToBeRemoved.connect(self.rowsAboutToBeRemoved, AUC) + self._model.rowsAboutToBeRemoved.connect(self.rowsAboutToBeRemoved, AUC) except TypeError: pass self.updateStatusBar() def setCurrentModelIndex(self, index=None): - if index.isValid(): + if index and index.isValid(): self.currentIndex = index self._model = index.model() self.currentID = self._model.ID(index) @@ -313,17 +311,26 @@ class editorWidget(QWidget, Ui_editorWidget_ui): if self._model: self.setView() - def updateIndexFromID(self): + def updateIndexFromID(self, fallback=None, ignore=None): """ Index might have changed (through drag an drop), so we keep current item's ID and update index. Item might have been deleted too. - """ - idx = self._model.getIndexByID(self.currentID) - # If we have an ID but the ID does not exist, it has been deleted + It will ignore the passed model item to avoid ambiguity during times + of inconsistent state. + """ + idx = self._model.getIndexByID(self.currentID, ignore=ignore) + + # If we have an ID but the ID does not exist, it has been deleted. if self.currentID and idx == QModelIndex(): - # Item has been deleted, we open the parent instead - self.setCurrentModelIndex(self.currentIndex.parent()) + # If we are given a fallback item to display, do so. + if fallback: + self.setCurrentModelIndex(fallback) + else: + # After tab closing is implemented, any calls to `updateIndexFromID` + # should be re-evaluated to match the desired behaviour. + raise NotImplementedError("implement tab closing") + # FIXME: selection in self.mw.treeRedacOutline is not updated # but we cannot simply setCurrentIndex through treeRedacOutline # because this might be a tab in the background / out of focus @@ -337,19 +344,39 @@ class editorWidget(QWidget, Ui_editorWidget_ui): self.setView() def modelDataChanged(self, topLeft, bottomRight): - # if self.currentID: - # self.updateIndexFromID() - if not self.currentIndex: - return + if not self.currentIndex.isValid(): + return # Just to be safe. + + # We are only concerned with minor changes to the current index, + # so there is no need to call updateIndexFromID() nor setView(). if topLeft.row() <= self.currentIndex.row() <= bottomRight.row(): + self.updateTabTitle() self.updateStatusBar() - #def rowsAboutToBeRemoved(self, parent, first, last): - #if self.currentIndex: - #if self.currentIndex.parent() == parent and \ - #first <= self.currentIndex.row() <= last: - ## Item deleted, close tab - #self.mw.mainEditor.tab.removeTab(self.mw.mainEditor.tab.indexOf(self)) + def rowsAboutToBeRemoved(self, parent, first, last): + if not self.currentIndex.isValid(): + return # Just to be safe. + + # Look for a common ancestor to verify whether the deleted rows include our index in their hierarchy. + childItem = self.currentIndex + ancestorCandidate = childItem.parent() # start at folder above current item + while (ancestorCandidate != parent): + childItem = ancestorCandidate + ancestorCandidate = childItem.parent() + + if not ancestorCandidate.isValid(): + return # we ran out of ancestors without finding the matching QModelIndex + + # My sanity advocates a healthy dose of paranoia. (Just to be safe.) + if ancestorCandidate != parent: + return # we did not find our shared ancestor + + # Verify our origins come from the relevant first..last range. + if first <= childItem.row() <= last: + # If the row in question was actually moved, there is a duplicate item + # already inserted elsewhere in the tree. Try to update this tab view, + # but make sure we exclude ourselves from the search for a replacement. + self.updateIndexFromID(fallback=parent, ignore=self.currentIndex.internalPointer()) def updateStatusBar(self): # Update progress @@ -359,7 +386,7 @@ class editorWidget(QWidget, Ui_editorWidget_ui): if not mw: return - mw.mainEditor.updateStats() + mw.mainEditor.tabChanged() def toggleSpellcheck(self, v): self.spellcheck = v diff --git a/manuskript/ui/editors/fullScreenEditor.py b/manuskript/ui/editors/fullScreenEditor.py index b9c8b45..ee9e06d 100644 --- a/manuskript/ui/editors/fullScreenEditor.py +++ b/manuskript/ui/editors/fullScreenEditor.py @@ -2,30 +2,32 @@ # --!-- coding: utf8 --!-- import os -from PyQt5.QtCore import Qt, QSize, QPoint, QRect, QEvent, QTimer -from PyQt5.QtGui import QFontMetrics, QColor, QBrush, QPalette, QPainter, QPixmap +from PyQt5.QtCore import Qt, QSize, QPoint, QRect, QEvent, QTime, QTimer, pyqtSignal +from PyQt5.QtGui import QFontMetrics, QColor, QBrush, QPalette, QPainter, QPixmap, QCursor from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QFrame, QWidget, QPushButton, qApp, QStyle, QComboBox, QLabel, QScrollBar, \ - QStyleOptionSlider, QHBoxLayout, QVBoxLayout, QMenu, QAction + QStyleOptionSlider, QHBoxLayout, QVBoxLayout, QMenu, QAction, QDesktopWidget # Spell checker support from manuskript import settings from manuskript.enums import Outline -from manuskript.functions import allPaths, drawProgress +from manuskript.models import outlineItem +from manuskript.functions import allPaths, drawProgress, safeTranslate from manuskript.ui.editors.locker import locker from manuskript.ui.editors.themes import findThemePath, generateTheme, setThemeEditorDatas from manuskript.ui.editors.themes import loadThemeDatas from manuskript.ui.views.MDEditView import MDEditView +from manuskript.functions import Spellchecker -try: - import enchant -except ImportError: - enchant = None - +import logging +LOGGER = logging.getLogger(__name__) class fullScreenEditor(QWidget): - def __init__(self, index, parent=None): + exited = pyqtSignal() + + def __init__(self, index, parent=None, screenNumber=None): QWidget.__init__(self, parent) + self.setAttribute(Qt.WA_DeleteOnClose, True) self._background = None self._index = index self._theme = findThemePath(settings.fullScreenTheme) @@ -53,23 +55,53 @@ class fullScreenEditor(QWidget): # self.topPanel.layout().addStretch(1) # Spell checking - if enchant: + if Spellchecker.isInstalled(): self.btnSpellCheck = QPushButton(self) self.btnSpellCheck.setFlat(True) self.btnSpellCheck.setIcon(QIcon.fromTheme("tools-check-spelling")) self.btnSpellCheck.setCheckable(True) self.btnSpellCheck.setChecked(self.editor.spellcheck) self.btnSpellCheck.toggled.connect(self.editor.toggleSpellcheck) - self.topPanel.layout().addWidget(self.btnSpellCheck) + else: + self.btnSpellCheck = None - self.topPanel.layout().addStretch(1) + # Navigation Buttons + self.btnPrevious = QPushButton(self) + self.btnPrevious.setFlat(True) + self.btnPrevious.setIcon(QIcon.fromTheme("arrow-left")) + self.btnPrevious.clicked.connect(self.switchPreviousItem) + self.btnNext = QPushButton(self) + self.btnNext.setFlat(True) + self.btnNext.setIcon(QIcon.fromTheme("arrow-right")) + self.btnNext.clicked.connect(self.switchNextItem) + self.btnNew = QPushButton(self) + self.btnNew.setFlat(True) + self.btnNew.setIcon(QIcon.fromTheme("document-new")) + self.btnNew.clicked.connect(self.createNewText) + + # Path and New Text Buttons + self.wPath = myPath(self) # Close self.btnClose = QPushButton(self) self.btnClose.setIcon(qApp.style().standardIcon(QStyle.SP_DialogCloseButton)) - self.btnClose.clicked.connect(self.close) + self.btnClose.clicked.connect(self.leaveFullscreen) self.btnClose.setFlat(True) + + # Top panel Layout + if self.btnSpellCheck: + self.topPanel.layout().addWidget(self.btnSpellCheck) + self.topPanel.layout().addSpacing(15) + self.topPanel.layout().addWidget(self.btnPrevious) + self.topPanel.layout().addWidget(self.btnNext) + self.topPanel.layout().addWidget(self.btnNew) + + self.topPanel.layout().addStretch(1) + self.topPanel.layout().addWidget(self.wPath) + self.topPanel.layout().addStretch(1) + self.topPanel.layout().addWidget(self.btnClose) + self.updateTopBar() # Left Panel self._locked = False @@ -98,7 +130,8 @@ class fullScreenEditor(QWidget): # self.lstThemes.setCurrentText(settings.fullScreenTheme) self.lstThemes.currentTextChanged.connect(self.setTheme) self.lstThemes.setMaximumSize(QSize(300, QFontMetrics(qApp.font()).height())) - self.bottomPanel.layout().addWidget(QLabel(self.tr("Theme:"), self)) + themeLabel = QLabel(self.tr("Theme:"), self) + self.bottomPanel.layout().addWidget(themeLabel) self.bottomPanel.layout().addWidget(self.lstThemes) self.bottomPanel.layout().addStretch(1) @@ -106,12 +139,39 @@ class fullScreenEditor(QWidget): self.lblProgress.setMaximumSize(QSize(200, 14)) self.lblProgress.setMinimumSize(QSize(100, 14)) self.lblWC = QLabel(self) + self.lblClock = myClockLabel(self) self.bottomPanel.layout().addWidget(self.lblWC) self.bottomPanel.layout().addWidget(self.lblProgress) + self.bottomPanel.layout().addSpacing(15) + self.bottomPanel.layout().addWidget(self.lblClock) self.updateStatusBar() self.bottomPanel.layout().addSpacing(24) + # Add Widget Settings + if self.btnSpellCheck: + self.topPanel.addWidgetSetting(self.tr("Spellcheck"), 'top-spellcheck', (self.btnSpellCheck, )) + self.topPanel.addWidgetSetting(self.tr("Navigation"), 'top-navigation', (self.btnPrevious, self.btnNext)) + self.topPanel.addWidgetSetting(self.tr("New Text"), 'top-new-doc', (self.btnNew, )) + self.topPanel.addWidgetSetting(self.tr("Title"), 'top-title', (self.wPath, )) + self.topPanel.addSetting(self.tr("Title: Show Full Path"), 'title-show-full-path', True) + self.topPanel.setSettingCallback('title-show-full-path', lambda var, val: self.updateTopBar()) + self.bottomPanel.addWidgetSetting(self.tr("Theme selector"), 'bottom-theme', (self.lstThemes, themeLabel)) + self.bottomPanel.addWidgetSetting(self.tr("Word count"), 'bottom-wc', (self.lblWC, )) + self.bottomPanel.addWidgetSetting(self.tr("Progress"), 'bottom-progress', (self.lblProgress, )) + self.bottomPanel.addSetting(self.tr("Progress: Auto Show/Hide"), 'progress-auto-show', True) + self.bottomPanel.addWidgetSetting(self.tr("Clock"), 'bottom-clock', (self.lblClock, )) + self.bottomPanel.addSetting(self.tr("Clock: Show Seconds"), 'clock-show-seconds', True) + self.bottomPanel.setAutoHideVariable('autohide-bottom') + self.topPanel.setAutoHideVariable('autohide-top') + self.leftPanel.setAutoHideVariable('autohide-left') + + # Set the screen to the same screen as the main window + if screenNumber is not None: + screenres = QDesktopWidget().screenGeometry(screenNumber); + self.move(QPoint(screenres.x(), screenres.y())); + self.resize(screenres.width(), screenres.height()); + # Connection self._index.model().dataChanged.connect(self.dataChanged) @@ -120,6 +180,15 @@ class fullScreenEditor(QWidget): # self.showMaximized() # self.show() + def leaveFullscreen(self): + self.__exit__("Leaving fullScreenEditor via leaveFullScreen.") + + def __exit__(self, message): + LOGGER.debug(message) + self.showNormal() + self.exited.emit() + self.close() + def setLocked(self, val): self._locked = val self.btnClose.setVisible(not val) @@ -142,11 +211,11 @@ class fullScreenEditor(QWidget): # Colors if self._themeDatas["Foreground/Color"] == self._themeDatas["Background/Color"] or \ self._themeDatas["Foreground/Opacity"] < 5: - self._bgcolor = QColor(self._themeDatas["Text/Color"]) - self._fgcolor = QColor(self._themeDatas["Background/Color"]) + self._fgcolor = QColor(self._themeDatas["Text/Color"]) + self._bgcolor = QColor(self._themeDatas["Background/Color"]) else: self._bgcolor = QColor(self._themeDatas["Foreground/Color"]) - self._bgcolor.setAlpha(self._themeDatas["Foreground/Opacity"] * 255 / 100) + self._bgcolor.setAlpha(int(self._themeDatas["Foreground/Opacity"] * 255 / 100)) self._fgcolor = QColor(self._themeDatas["Text/Color"]) if self._themeDatas["Text/Color"] == self._themeDatas["Foreground/Color"]: self._fgcolor = QColor(self._themeDatas["Background/Color"]) @@ -170,7 +239,7 @@ class fullScreenEditor(QWidget): r = self.locker.geometry() r.moveTopLeft(QPoint( 0, - self.geometry().height() / 2 - r.height() / 2 + int(self.geometry().height() / 2 - r.height() / 2) )) self.leftPanel.setGeometry(r) self.hideWidget(self.leftPanel) @@ -197,7 +266,7 @@ class fullScreenEditor(QWidget): p.setBrush(QPalette.ButtonText, self._fgcolor) p.setBrush(QPalette.WindowText, self._fgcolor) - for panel in (self.bottomPanel, self.topPanel): + for panel in (self.bottomPanel, self.topPanel, self.leftPanel): for i in range(panel.layout().count()): item = panel.layout().itemAt(i) if item.widget(): @@ -221,7 +290,15 @@ class fullScreenEditor(QWidget): def keyPressEvent(self, event): if event.key() in [Qt.Key_Escape, Qt.Key_F11] and \ not self._locked: - self.close() + self.__exit__("Leaving fullScreenEditor via keyPressEvent.") + elif (event.modifiers() & Qt.AltModifier) and \ + event.key() in [Qt.Key_PageUp, Qt.Key_PageDown, Qt.Key_Left, Qt.Key_Right]: + if event.key() in [Qt.Key_PageUp, Qt.Key_Left]: + success = self.switchPreviousItem() + if event.key() in [Qt.Key_PageDown, Qt.Key_Right]: + success = self.switchNextItem() + if not success: + QWidget.keyPressEvent(self, event) else: QWidget.keyPressEvent(self, event) @@ -265,6 +342,14 @@ class fullScreenEditor(QWidget): if topLeft.row() <= self._index.row() <= bottomRight.row(): self.updateStatusBar() + def updateTopBar(self): + item = self._index.internalPointer() + previousItem = self.previousTextItem(item) + nextItem = self.nextTextItem(item) + self.btnPrevious.setEnabled(previousItem != None) + self.btnNext.setEnabled(nextItem != None) + self.wPath.setItem(item) + def updateStatusBar(self): if self._index: item = self._index.internalPointer() @@ -274,18 +359,22 @@ class fullScreenEditor(QWidget): pg = item.data(Outline.goalPercentage) if goal: - rect = self.lblProgress.geometry() - rect = QRect(QPoint(0, 0), rect.size()) - self.px = QPixmap(rect.size()) - self.px.fill(Qt.transparent) - p = QPainter(self.px) - drawProgress(p, rect, pg, 2) - p.end() - self.lblProgress.setPixmap(self.px) + if settings.fullscreenSettings.get("progress-auto-show", True): + self.lblProgress.show() self.lblWC.setText(self.tr("{} words / {}").format(wc, goal)) else: - self.lblProgress.hide() + if settings.fullscreenSettings.get("progress-auto-show", True): + self.lblProgress.hide() self.lblWC.setText(self.tr("{} words").format(wc)) + pg = 0 + rect = self.lblProgress.geometry() + rect = QRect(QPoint(0, 0), rect.size()) + self.px = QPixmap(rect.size()) + self.px.fill(Qt.transparent) + p = QPainter(self.px) + drawProgress(p, rect, pg, 2) + p.end() + self.lblProgress.setPixmap(self.px) self.locker.setWordCount(wc) # If there's a goal, then we update the locker target's number of word accordingly @@ -296,6 +385,102 @@ class fullScreenEditor(QWidget): elif not wc: self.locker.spnWordTarget.setValue(goal) + def setCurrentModelIndex(self, index): + self._index = index + self.editor.setCurrentModelIndex(index) + self.updateTopBar() + self.updateStatusBar() + + def switchPreviousItem(self): + item = self._index.internalPointer() + previousItem = self.previousTextItem(item) + if previousItem: + self.setCurrentModelIndex(previousItem.index()) + return True + return False + + def switchNextItem(self): + item = self._index.internalPointer() + nextItem = self.nextTextItem(item) + if nextItem: + self.setCurrentModelIndex(nextItem.index()) + return True + return False + + def switchToItem(self, item): + item = self.firstTextItem(item) + if item: + self.setCurrentModelIndex(item.index()) + + def createNewText(self): + item = self._index.internalPointer() + newItem = outlineItem(title=safeTranslate(qApp, "outlineBasics", "New"), _type=settings.defaultTextType) + self._index.model().insertItem(newItem, item.row() + 1, item.parent().index()) + self.setCurrentModelIndex(newItem.index()) + + def previousModelItem(self, item): + parent = item.parent() + if not parent: + # Root has no sibling + return None + + row = parent.childItems.index(item) + if row > 0: + return parent.child(row - 1) + return self.previousModelItem(parent) + + def nextModelItem(self, item): + parent = item.parent() + if not parent: + # Root has no sibling + return None + + row = parent.childItems.index(item) + if row + 1 < parent.childCount(): + return parent.child(row + 1) + return self.nextModelItem(parent) + + def previousTextItem(self, item): + previous = self.previousModelItem(item) + + while previous: + last = self.lastTextItem(previous) + if last: + return last + previous = self.previousModelItem(previous) + return None + + def nextTextItem(self, item): + if item.isFolder() and item.childCount() > 0: + next = item.child(0) + else: + next = self.nextModelItem(item) + + while next: + first = self.firstTextItem(next) + if first: + return first + next = self.nextModelItem(next) + return None + + def firstTextItem(self, item): + if item.isText(): + return item + for child in item.children(): + first = self.firstTextItem(child) + if first: + return first + return None + + def lastTextItem(self, item): + if item.isText(): + return item + for child in reversed(item.children()): + last = self.lastTextItem(child) + if last: + return last + return None + class myScrollBar(QScrollBar): def __init__(self, color=Qt.white, parent=None): @@ -305,11 +490,14 @@ class myScrollBar(QScrollBar): self.timer = QTimer() self.timer.setInterval(500) self.timer.setSingleShot(True) - self.timer.timeout.connect(lambda: self.parent().hideWidget(self)) + self.timer.timeout.connect(self.hide) self.valueChanged.connect(lambda v: self.timer.start()) self.valueChanged.connect(lambda: self.parent().showWidget(self)) self.rangeChanged.connect(self.rangeHasChanged) + def hide(self): + self.parent().hideWidget(self) + def setColor(self, color): self._color = color @@ -349,6 +537,10 @@ class myPanel(QWidget): self.show() self.setAttribute(Qt.WA_TranslucentBackground) self._autoHide = True + self._m = None + self._autoHideVar = None + self._settings = [] + self._callbacks = {} if not vertical: self.setLayout(QHBoxLayout()) @@ -364,16 +556,144 @@ class myPanel(QWidget): painter = QPainter(self) painter.fillRect(r, self._color) + def _setConfig(self, config_name, value): + settings.fullscreenSettings[config_name] = value + if config_name in self._callbacks: + self._callbacks[config_name](config_name, value) + + def _setSettingValue(self, setting, value): + if setting[2]: + for w in setting[2]: + w.show() if value else w.hide() + self._setConfig(setting[1], value) + def setAutoHide(self, value): self._autoHide = value + if self._autoHideVar: + self._setConfig(self._autoHideVar, value) + + def setAutoHideVariable(self, name): + if name: + self.setAutoHide(settings.fullscreenSettings[name]) + self._autoHideVar = name + + def addWidgetSetting(self, label, config_name, widgets): + setting = (label, config_name, widgets) + self._settings.append(setting) + if settings.fullscreenSettings.get(config_name, None) != None: + self._setSettingValue(setting, settings.fullscreenSettings[config_name]) + + def addSetting(self, label, config_name, default=True): + if settings.fullscreenSettings.get(config_name, None) == None: + self._setConfig(config_name, default) + self.addWidgetSetting(label, config_name, None) + + def setSettingCallback(self, config_name, callback): + self._callbacks[config_name] = callback def mouseReleaseEvent(self, event): if event.button() == Qt.RightButton: + if self._m: + self._m.deleteLater() m = QMenu() a = QAction(self.tr("Auto-hide"), m) a.setCheckable(True) a.setChecked(self._autoHide) a.toggled.connect(self.setAutoHide) m.addAction(a) + for item in self._settings: + a = QAction(item[0], m) + a.setCheckable(True) + if item[2]: + a.setChecked(item[2][0].isVisible()) + else: + a.setChecked(settings.fullscreenSettings[item[1]]) + def gen_cb(setting): + return lambda v: self._setSettingValue(setting, v) + a.toggled.connect(gen_cb(item)) + m.addAction(a) m.popup(self.mapToGlobal(event.pos())) self._m = m + +class myPath(QWidget): + def __init__(self, parent=None): + QWidget.__init__(self, parent) + self.editor = parent + self.setAttribute(Qt.WA_TranslucentBackground) + self.setLayout(QHBoxLayout()) + self.layout().setContentsMargins(0, 0, 0, 0) + + def setItem(self, item): + self._item = item + path = self.getItemPath(item) + layout = self.layout() + while layout.count() > 0: + li = layout.takeAt(0) + w = li.widget() + w.deleteLater() + + def gen_cb(i): + return lambda: self.popupPath(i) + # Skip Root + for i in path[1:]: + if not settings.fullscreenSettings.get("title-show-full-path", True) and \ + i.isFolder(): + continue + btn = QPushButton(i.title(), self) + btn.setFlat(True) + btn.clicked.connect(gen_cb(i)) + self.layout().addWidget(btn) + if i.isFolder(): + lblSeparator = QLabel(" > ", self) + #lblSeparator = QLabel(self) + #lblSeparator.setPixmap(QIcon.fromTheme("view-list-tree").pixmap(24,24)) + self.layout().addWidget(lblSeparator) + + def popupPath(self, item): + m = QMenu() + def gen_cb(i): + return lambda: self.editor.switchToItem(i) + + for i in item.siblings(): + a = QAction(i.title(), m) + if i == item: + a.setIcon(QIcon.fromTheme("stock_yes")) + a.setEnabled(False) + elif self.editor.firstTextItem(i) == None: + a.setEnabled(False) + else: + a.triggered.connect(gen_cb(i)) + m.addAction(a) + m.popup(QCursor.pos()) + self._m = m + + def getItemPath(self, item): + path = [item] + parent = item.parent() + while parent: + path.insert(0, parent) + parent = parent.parent() + return path + + +class myClockLabel(QLabel): + + + def __init__(self, parent=None): + QLabel.__init__(self, parent) + + self.updateClock() + self.timer = QTimer() + self.timer.setInterval(1000) + self.timer.timeout.connect(self.updateClock) + self.timer.start() + + + def updateClock(self): + time = QTime.currentTime() + if settings.fullscreenSettings.get("clock-show-seconds", True): + timeStr = time.toString("hh:mm:ss") + else: + timeStr = time.toString("hh:mm") + + self.setText(timeStr) diff --git a/manuskript/ui/editors/locker.py b/manuskript/ui/editors/locker.py index d9a648e..2a007a3 100644 --- a/manuskript/ui/editors/locker.py +++ b/manuskript/ui/editors/locker.py @@ -105,6 +105,6 @@ class locker(QWidget, Ui_locker): text)) # Word locked - elif self._target is not None: + elif self._target != None: self.btnLock.setText(self.tr("{} words remaining").format( self._target - self._words)) diff --git a/manuskript/ui/editors/mainEditor.py b/manuskript/ui/editors/mainEditor.py index 35a9274..7ee4300 100644 --- a/manuskript/ui/editors/mainEditor.py +++ b/manuskript/ui/editors/mainEditor.py @@ -5,11 +5,11 @@ import locale from PyQt5.QtCore import QModelIndex, QRect, QPoint from PyQt5.QtCore import Qt from PyQt5.QtGui import QPixmap, QPainter, QIcon -from PyQt5.QtWidgets import QWidget, qApp +from PyQt5.QtWidgets import QWidget, qApp, QDesktopWidget from manuskript import settings from manuskript.enums import Outline -from manuskript.functions import AUC, mainWindow, drawProgress, appPath +from manuskript.functions import AUC, mainWindow, drawProgress, appPath, uiParse from manuskript.ui import style from manuskript.ui.editors.editorWidget import editorWidget from manuskript.ui.editors.fullScreenEditor import fullScreenEditor @@ -20,6 +20,9 @@ try: except: pass +import logging +LOGGER = logging.getLogger(__name__) + class mainEditor(QWidget, Ui_mainEditor): """ `mainEditor` is responsible for opening `outlineItem`s and offering information @@ -64,6 +67,7 @@ class mainEditor(QWidget, Ui_mainEditor): QWidget.__init__(self, parent) self.setupUi(self) self._updating = False + self._fullScreen = None self.mw = mainWindow() @@ -120,7 +124,7 @@ class mainEditor(QWidget, Ui_mainEditor): return self.tabSplitter.tab def currentEditor(self, tabWidget=None): - if tabWidget is None: + if tabWidget == None: tabWidget = self.currentTabWidget() return tabWidget.currentWidget() # return self.tab.currentWidget() @@ -151,9 +155,13 @@ class mainEditor(QWidget, Ui_mainEditor): for ts in reversed(self.allTabSplitters()): ts.closeSplit() + def close(self): + if self._fullScreen is not None: + self._fullScreen.leaveFullscreen() + def allTabs(self, tabWidget=None): """Returns all the tabs from the given tabWidget. If tabWidget is None, from the current tabWidget.""" - if tabWidget is None: + if tabWidget == None: tabWidget = self.currentTabWidget() return [tabWidget.widget(i) for i in range(tabWidget.count())] @@ -205,7 +213,7 @@ class mainEditor(QWidget, Ui_mainEditor): title = self.getIndexTitle(index) - if tabWidget is None: + if tabWidget == None: tabWidget = self.currentTabWidget() # Checking if tab is already opened @@ -292,6 +300,7 @@ class mainEditor(QWidget, Ui_mainEditor): return index = self.currentEditor().currentIndex + if index.isValid(): item = index.internalPointer() else: @@ -300,13 +309,21 @@ class mainEditor(QWidget, Ui_mainEditor): if not item: item = self.mw.mdlOutline.rootItem + cc = item.data(Outline.charCount) wc = item.data(Outline.wordCount) goal = item.data(Outline.goal) + chars = item.data(Outline.charCount) # len(item.data(Outline.text)) progress = item.data(Outline.goalPercentage) - # mw = qApp.activeWindow() + goal = uiParse(goal, None, int, lambda x: x>=0) + progress = uiParse(progress, 0.0, float) + + if not cc: + cc = 0 + if not wc: wc = 0 + if goal: self.lblRedacProgress.show() rect = self.lblRedacProgress.geometry() @@ -317,13 +334,31 @@ class mainEditor(QWidget, Ui_mainEditor): drawProgress(p, rect, progress, 2) del p self.lblRedacProgress.setPixmap(self.px) - self.lblRedacWC.setText(self.tr("{} words / {} ").format( - locale.format_string("%d", wc, grouping=True), - locale.format_string("%d", goal, grouping=True))) + + if settings.progressChars: + self.lblRedacWC.setText(self.tr("({} chars) {} words / {} ").format( + locale.format("%d", cc, grouping=True), + locale.format("%d", wc, grouping=True), + locale.format("%d", goal, grouping=True))) + self.lblRedacWC.setToolTip("") + else: + self.lblRedacWC.setText(self.tr("{} words / {} ").format( + locale.format("%d", wc, grouping=True), + locale.format("%d", goal, grouping=True))) + self.lblRedacWC.setToolTip(self.tr("{} chars").format( + locale.format("%d", cc, grouping=True))) else: self.lblRedacProgress.hide() - self.lblRedacWC.setText(self.tr("{} words ").format( - locale.format_string("%d", wc, grouping=True))) + + if settings.progressChars: + self.lblRedacWC.setText(self.tr("{} chars ").format( + locale.format("%d", cc, grouping=True))) + self.lblRedacWC.setToolTip("") + else: + self.lblRedacWC.setText(self.tr("{} words ").format( + locale.format("%d", wc, grouping=True))) + self.lblRedacWC.setToolTip(self.tr("{} chars").format( + locale.format("%d", cc, grouping=True))) ############################################################################### # VIEWS @@ -352,14 +387,21 @@ class mainEditor(QWidget, Ui_mainEditor): def showFullScreen(self): if self.currentEditor(): - self._fullScreen = fullScreenEditor(self.currentEditor().currentIndex) + currentScreenNumber = QDesktopWidget().screenNumber(widget=self) + self._fullScreen = fullScreenEditor( + self.currentEditor().currentIndex, + screenNumber=currentScreenNumber) + # Clean the variable when closing fullscreen prevent errors + self._fullScreen.exited.connect(self.clearFullScreen) + + def clearFullScreen(self): + self._fullScreen = None ############################################################################### # DICT AND STUFF LIKE THAT ############################################################################### def setDict(self, dict): - print(dict) for w in self.allAllTabs(): w.setDict(dict) diff --git a/manuskript/ui/editors/tabSplitter.py b/manuskript/ui/editors/tabSplitter.py index 13adfaf..d6f4ff3 100644 --- a/manuskript/ui/editors/tabSplitter.py +++ b/manuskript/ui/editors/tabSplitter.py @@ -10,6 +10,8 @@ from manuskript.functions import mainWindow, appPath from manuskript.ui import style from manuskript.ui.editors.tabSplitter_ui import Ui_tabSplitter +import logging +LOGGER = logging.getLogger(__name__) class tabSplitter(QWidget, Ui_tabSplitter): """ @@ -39,7 +41,7 @@ class tabSplitter(QWidget, Ui_tabSplitter): # try: # self.tab.setTabBarAutoHide(True) # except AttributeError: - # print("Info: install Qt 5.4 or higher to use tab bar auto-hide in editor.") + # LOGGER.info("Install Qt 5.4 or higher to use tab bar auto-hide in editor.") # Button to split self.btnSplit = QPushButton(self) @@ -145,8 +147,8 @@ class tabSplitter(QWidget, Ui_tabSplitter): def split(self, toggled=None, state=None): - if state is None and self.splitState == 0 or state == 1: - if self.secondTab is None: + if state == None and self.splitState == 0 or state == 1: + if self.secondTab == None: self.addSecondTab() self.splitState = 1 @@ -155,8 +157,8 @@ class tabSplitter(QWidget, Ui_tabSplitter): self.btnSplit.setIcon(QIcon.fromTheme("split-vertical")) self.btnSplit.setToolTip(self.tr("Split horizontally")) - elif state is None and self.splitState == 1 or state == 2: - if self.secondTab is None: + elif state == None and self.splitState == 1 or state == 2: + if self.secondTab == None: self.addSecondTab() self.splitter.setOrientation(Qt.Vertical) @@ -212,7 +214,7 @@ class tabSplitter(QWidget, Ui_tabSplitter): # self.btnSplit.setGeometry(QRect(0, 0, 24, 24)) def focusChanged(self, old, new): - if self.secondTab is None or new is None: + if self.secondTab == None or new == None: return oldFT = self.focusTab diff --git a/manuskript/ui/editors/themes.py b/manuskript/ui/editors/themes.py index 7ba8715..1561b71 100644 --- a/manuskript/ui/editors/themes.py +++ b/manuskript/ui/editors/themes.py @@ -83,7 +83,7 @@ def themeTextRect(themeDatas, screenRect): elif themeDatas["Foreground/Position"] == 3: # Stretched x = margin width = screenRect.width() - 2 * margin - return QRect(x, y, width, height) + return QRect(int(x), int(y), int(width), int(height)) def createThemePreview(theme, screenRect, size=QSize(200, 120)): @@ -116,13 +116,13 @@ def createThemePreview(theme, screenRect, size=QSize(200, 120)): px = QPixmap(pixmap).scaled(size, Qt.KeepAspectRatio) - w = px.width() / 10 - h = px.height() / 10 + w = int(px.width() / 10) + h = int(px.height() / 10) r = themeTextRect(themeDatas, screenRect) painter = QPainter(px) painter.drawPixmap(QRect(w, h, w * 4, h * 5), pixmap, - QRect(r.topLeft() - QPoint(w / 3, h / 3), QSize(w * 4, h * 5))) + QRect(r.topLeft() - QPoint(int(w / 3), int(h / 3)), QSize(w * 4, h * 5))) painter.setPen(Qt.white) painter.drawRect(QRect(w, h, w * 4, h * 5)) painter.end() @@ -164,15 +164,15 @@ def generateTheme(themeDatas, screenRect): elif _type == 5: # Zoomed scaled.scale(screenRect.size(), Qt.KeepAspectRatioByExpanding) - painter.drawImage((screenRect.width() - scaled.width()) / 2, - (screenRect.height() - scaled.height()) / 2, img.scaled(scaled)) + painter.drawImage(int((screenRect.width() - scaled.width()) / 2), + int((screenRect.height() - scaled.height()) / 2), img.scaled(scaled)) # Text Background textRect = themeTextRect(themeDatas, screenRect) painter.save() color = QColor(themeDatas["Foreground/Color"]) - color.setAlpha(themeDatas["Foreground/Opacity"] * 255 / 100) + color.setAlpha(int(themeDatas["Foreground/Opacity"] * 255 / 100)) painter.setBrush(color) painter.setPen(Qt.NoPen) r = themeDatas["Foreground/Rounding"] @@ -249,12 +249,14 @@ def setThemeEditorDatas(editor, themeDatas, pixmap, screenRect): # editor.setFont(f) editor.setStyleSheet(""" - background: transparent; - color: {foreground}; - font-family: {ff}; - font-size: {fs}; - selection-color: {sc}; - selection-background-color: {sbc}; + QTextEdit {{ + background: transparent; + color: {foreground}; + font-family: {ff}; + font-size: {fs}; + selection-color: {sc}; + selection-background-color: {sbc}; + }} """.format( foreground=themeDatas["Text/Color"], ff=f.family(), diff --git a/manuskript/ui/exporters/exporter.py b/manuskript/ui/exporters/exporter.py index 7a954bc..68920c4 100644 --- a/manuskript/ui/exporters/exporter.py +++ b/manuskript/ui/exporters/exporter.py @@ -3,7 +3,7 @@ import json import os -from PyQt5.QtCore import Qt +from PyQt5.QtCore import Qt, QPoint from PyQt5.QtGui import QBrush, QColor, QIcon from PyQt5.QtWidgets import QWidget, QStyle @@ -138,7 +138,7 @@ class exporterDialog(QWidget, Ui_exporter): r = self.dialog.geometry() r2 = self.geometry() - self.dialog.move(r2.center() - r.center()) + self.dialog.move(r2.center() - QPoint(int(r.width()/2), int(r.height()/2))) self.dialog.exportersMightHaveChanged.connect(self.populateExportList) @@ -153,4 +153,4 @@ class exporterDialog(QWidget, Ui_exporter): item.widget().deleteLater() l.addWidget(widget) - widget.setParent(group) \ No newline at end of file + widget.setParent(group) diff --git a/manuskript/ui/exporters/manuskript/plainTextSettings.py b/manuskript/ui/exporters/manuskript/plainTextSettings.py index 78fd001..bfa4e2a 100644 --- a/manuskript/ui/exporters/manuskript/plainTextSettings.py +++ b/manuskript/ui/exporters/manuskript/plainTextSettings.py @@ -128,7 +128,7 @@ class exporterSettings(QWidget, Ui_exporterSettings): def loadSettings(self): filename = self.getSettingsPath() if os.path.exists(filename): - with open(filename) as f: + with open(filename, "r", encoding="utf-8") as f: self.settings = json.load(f) self.updateFromSettings() @@ -138,7 +138,7 @@ class exporterSettings(QWidget, Ui_exporterSettings): def writeSettings(self): self.getSettings() - with open(self.getSettingsPath(), 'w') as f: + with open(self.getSettingsPath(), 'w', encoding="utf-8") as f: # json.dumps(json.loads(json.dumps(allSettings)), indent=4, sort_keys=True) json.dump(self.settings, f, indent=4, sort_keys=True) diff --git a/manuskript/ui/exporters/manuskript/plainTextSettings_ui.py b/manuskript/ui/exporters/manuskript/plainTextSettings_ui.py index e8f36c7..971746a 100644 --- a/manuskript/ui/exporters/manuskript/plainTextSettings_ui.py +++ b/manuskript/ui/exporters/manuskript/plainTextSettings_ui.py @@ -2,12 +2,15 @@ # Form implementation generated from reading ui file 'manuskript/ui/exporters/manuskript/plainTextSettings_ui.ui' # -# Created by: PyQt5 UI code generator 5.9 +# Created by: PyQt5 UI code generator 5.15.4 # -# WARNING! All changes made in this file will be lost! +# WARNING: Any manual changes made to this file will be lost when pyuic5 is +# run again. Do not edit this file unless you know what you are doing. + from PyQt5 import QtCore, QtGui, QtWidgets + class Ui_exporterSettings(object): def setupUi(self, exporterSettings): exporterSettings.setObjectName("exporterSettings") @@ -22,7 +25,6 @@ class Ui_exporterSettings(object): self.content.setGeometry(QtCore.QRect(0, 0, 349, 842)) self.content.setObjectName("content") self.verticalLayout_5 = QtWidgets.QVBoxLayout(self.content) - self.verticalLayout_5.setContentsMargins(0, 0, 0, 0) self.verticalLayout_5.setObjectName("verticalLayout_5") self.label = QtWidgets.QLabel(self.content) self.label.setObjectName("label") @@ -106,7 +108,6 @@ class Ui_exporterSettings(object): self.separations.setGeometry(QtCore.QRect(0, 0, 173, 336)) self.separations.setObjectName("separations") self.verticalLayout_8 = QtWidgets.QVBoxLayout(self.separations) - self.verticalLayout_8.setContentsMargins(0, 0, 0, 0) self.verticalLayout_8.setObjectName("verticalLayout_8") self.label_3 = QtWidgets.QLabel(self.separations) font = QtGui.QFont() @@ -316,7 +317,6 @@ class Ui_exporterSettings(object): self.transformations.setStyleSheet("QGroupBox{font-weight:bold;}") self.transformations.setObjectName("transformations") self.verticalLayout_6 = QtWidgets.QVBoxLayout(self.transformations) - self.verticalLayout_6.setContentsMargins(0, 0, 0, 0) self.verticalLayout_6.setObjectName("verticalLayout_6") self.grpTransTypo = collapsibleGroupBox2(self.transformations) self.grpTransTypo.setStyleSheet("") @@ -479,7 +479,6 @@ class Ui_exporterSettings(object): self.preview.setStyleSheet("QGroupBox{font-weight:bold;}") self.preview.setObjectName("preview") self.verticalLayout_11 = QtWidgets.QVBoxLayout(self.preview) - self.verticalLayout_11.setContentsMargins(0, 0, 0, 0) self.verticalLayout_11.setObjectName("verticalLayout_11") self.groupBox = QtWidgets.QGroupBox(self.preview) self.groupBox.setObjectName("groupBox") @@ -560,5 +559,4 @@ class Ui_exporterSettings(object): self.label_7.setText(_translate("exporterSettings", "Font:")) self.label_8.setText(_translate("exporterSettings", "Font size:")) self.toolBox.setItemText(self.toolBox.indexOf(self.preview), _translate("exporterSettings", "Preview")) - from manuskript.ui.collapsibleGroupBox2 import collapsibleGroupBox2 diff --git a/manuskript/ui/highlighters/basicHighlighter.py b/manuskript/ui/highlighters/basicHighlighter.py index 6e45eba..2e7358e 100644 --- a/manuskript/ui/highlighters/basicHighlighter.py +++ b/manuskript/ui/highlighters/basicHighlighter.py @@ -1,5 +1,5 @@ #!/usr/bin/python -# -*- coding: utf8 -*- +# -*- coding: utf-8 -*- import re @@ -12,13 +12,14 @@ import manuskript.ui.style as S from manuskript import settings from manuskript import functions as F +import logging +LOGGER = logging.getLogger(__name__) class BasicHighlighter(QSyntaxHighlighter): def __init__(self, editor): QSyntaxHighlighter.__init__(self, editor.document()) self.editor = editor - self._misspelledColor = Qt.red self._defaultBlockFormat = QTextBlockFormat() self._defaultCharFormat = QTextCharFormat() self.defaultTextColor = QColor(S.text) @@ -27,6 +28,40 @@ class BasicHighlighter(QSyntaxHighlighter): self.linkColor = QColor(S.link) self.spellingErrorColor = QColor(Qt.red) + # Matches during checking can be separated by their type (all of them listed here): + # https://languagetool.org/development/api/org/languagetool/rules/ITSIssueType.html + # + # These are the colors for actual spell-, grammar- and style-checking: + self._errorColors = { + 'addition' : QColor(255, 215, 0), # gold + 'characters' : QColor(135, 206, 235), # sky blue + 'duplication' : QColor(0, 255, 255), # cyan / aqua + 'formatting' : QColor(0, 128, 128), # teal + 'grammar' : QColor(0, 0, 255), # blue + 'inconsistency' : QColor(128, 128, 0), # olive + 'inconsistententities' : QColor(46, 139, 87), # sea green + 'internationalization' : QColor(255, 165, 0), # orange + 'legal' : QColor(255, 69, 0), # orange red + 'length' : QColor(47, 79, 79), # dark slate gray + 'localespecificcontent' : QColor(188, 143, 143),# rosy brown + 'localeviolation' : QColor(128, 0, 0), # maroon + 'markup' : QColor(128, 0, 128), # purple + 'misspelling' : QColor(255, 0, 0), # red + 'mistranslation' : QColor(255, 0, 255), # magenta / fuchsia + 'nonconformance' : QColor(255, 218, 185), # peach puff + 'numbers' : QColor(65, 105, 225), # royal blue + 'omission' : QColor(255, 20, 147), # deep pink + 'other' : QColor(138, 43, 226), # blue violet + 'patternproblem' : QColor(0, 128, 0), # green + 'register' : QColor(112,128,144), # slate gray + 'style' : QColor(0, 255, 0), # lime + 'terminology' : QColor(0, 0, 128), # navy + 'typographical' : QColor(255, 255, 0), # yellow + 'uncategorized' : QColor(128, 128, 128), # gray + 'untranslated' : QColor(210, 105, 30), # chocolate + 'whitespace' : QColor(192, 192, 192) # silver + } + def setDefaultBlockFormat(self, bf): self._defaultBlockFormat = bf self.rehighlight() @@ -36,7 +71,7 @@ class BasicHighlighter(QSyntaxHighlighter): self.rehighlight() def setMisspelledColor(self, color): - self._misspelledColor = color + self._errorColors['misspelled'] = color def updateColorScheme(self, rehighlight=True): """ @@ -86,7 +121,7 @@ class BasicHighlighter(QSyntaxHighlighter): def doHighlightBlock(self, text): """ - Virtual funtion to subclass. + Virtual function to subclass. """ pass @@ -97,14 +132,14 @@ class BasicHighlighter(QSyntaxHighlighter): before you do any custom highlighting. Or implement doHighlightBlock. """ - #print(">", self.currentBlock().document().availableUndoSteps()) + #LOGGER.debug("undoSteps before: %s", self.currentBlock().document().availableUndoSteps()) c = QTextCursor(self.currentBlock()) #c.joinPreviousEditBlock() bf = QTextBlockFormat(self._defaultBlockFormat) if bf != c.blockFormat(): c.setBlockFormat(bf) #c.endEditBlock() - #print(" ", self.currentBlock().document().availableUndoSteps()) + #LOGGER.debug("undoSteps after: %s", self.currentBlock().document().availableUndoSteps()) # self.setFormat(0, len(text), self._defaultCharFormat) @@ -134,29 +169,25 @@ class BasicHighlighter(QSyntaxHighlighter): txt.end() - txt.start(), fmt) - # Spell checking + if hasattr(self.editor, "spellcheck") and self.editor.spellcheck and self.editor._dict: + # Spell checking - # Following algorithm would not check words at the end of line. - # This hacks adds a space to every line where the text cursor is not - # So that it doesn't spellcheck while typing, but still spellchecks at - # end of lines. See github's issue #166. - textedText = text - if self.currentBlock().position() + len(text) != \ - self.editor.textCursor().position(): - textedText = text + " " + # Following algorithm would not check words at the end of line. + # This hacks adds a space to every line where the text cursor is not + # So that it doesn't spellcheck while typing, but still spellchecks at + # end of lines. See github's issue #166. + textedText = text + if self.currentBlock().position() + len(text) != \ + self.editor.textCursor().position(): + textedText = text + " " - # Based on http://john.nachtimwald.com/2009/08/22/qplaintextedit-with-in-line-spell-check/ - WORDS = r'(?iu)(((?!_)[\w\'])+)' - # (?iu) means case insensitive and Unicode - # (?!_) means perform negative lookahead to exclude "_" from pattern match. See issue #283 - if hasattr(self.editor, "spellcheck") and self.editor.spellcheck: - for word_object in re.finditer(WORDS, textedText): - if (self.editor._dict - and not self.editor._dict.check(word_object.group(1))): - format = self.format(word_object.start(1)) - format.setUnderlineColor(self._misspelledColor) + # The text should only be checked once as a whole + for match in self.editor._dict.checkText(textedText): + if match.locqualityissuetype in self._errorColors: + highlight_color = self._errorColors[match.locqualityissuetype] + + format = self.format(match.start) + format.setUnderlineColor(highlight_color) # SpellCheckUnderline fails with some fonts format.setUnderlineStyle(QTextCharFormat.WaveUnderline) - self.setFormat(word_object.start(1), - word_object.end(1) - word_object.start(1), - format) + self.setFormat(match.start, match.end - match.start, format) diff --git a/manuskript/ui/highlighters/markdownHighlighter.py b/manuskript/ui/highlighters/markdownHighlighter.py index 786cae8..3a1564e 100644 --- a/manuskript/ui/highlighters/markdownHighlighter.py +++ b/manuskript/ui/highlighters/markdownHighlighter.py @@ -85,7 +85,7 @@ class MarkdownHighlighter(BasicHighlighter): def unfocusConditions(self): """ Returns: - - True if the text is suposed to be unfocused + - True if the text is supposed to be unfocused - (start, end) if block is supposed to be unfocused except for that part. """ @@ -283,7 +283,7 @@ class MarkdownHighlighter(BasicHighlighter): theme = { "markup": markup} - #Exemple: + #Example: #"color": Qt.red, #"deltaSize": 10, #"background": Qt.yellow, @@ -477,7 +477,7 @@ class MarkdownHighlighter(BasicHighlighter): self.transparentFormat(fmt) self.transparentFormat(markupFormat) - # Format openning Markup + # Format opening Markup self.setFormat(token.position, token.openingMarkupLength, markupFormat) @@ -676,7 +676,7 @@ class MarkdownHighlighter(BasicHighlighter): spellingErrorFormat = self.format(startIndex) spellingErrorFormat.setUnderlineColor(self.spellingErrorColor) spellingErrorFormat.setUnderlineStyle( - qApp.stlye().styleHint(QStyle.SH_SpellCheckUnderlineStyle)) + qApp.style().styleHint(QStyle.SH_SpellCheckUnderlineStyle)) self.setFormat(startIndex, length, spellingErrorFormat) @@ -713,7 +713,7 @@ class MarkdownHighlighter(BasicHighlighter): # FIXME: TypeError: could not convert 'TextBlockData' to 'QTextBlockUserData' # blockData = self.currentBlockUserData() - # if blockData is None: + # if blockData == None: # blockData = TextBlockData(self.document(), self.currentBlock()) # # self.setCurrentBlockUserData(blockData) diff --git a/manuskript/ui/highlighters/markdownTokenizer.py b/manuskript/ui/highlighters/markdownTokenizer.py index 0b8aece..237b18e 100644 --- a/manuskript/ui/highlighters/markdownTokenizer.py +++ b/manuskript/ui/highlighters/markdownTokenizer.py @@ -9,6 +9,9 @@ from PyQt5.QtWidgets import * from manuskript.ui.highlighters import MarkdownState as MS from manuskript.ui.highlighters import MarkdownTokenType as MTT +import logging +LOGGER = logging.getLogger(__name__) + # This file is simply a python translation of GhostWriter's Tokenizer. # http://wereturtle.github.io/ghostwriter/ # GPLV3+. @@ -56,7 +59,7 @@ class HighlightTokenizer: self.tokens.append(token) if token.type == -1: - print("Error here", token.position, token.length) + LOGGER.error("Token type invalid: position %s, length %s.", token.position, token.length) def setState(self, state): self.state = state @@ -279,7 +282,9 @@ class MarkdownTokenizer(HighlightTokenizer): if level > 0 and level < len(text): # Count how many pound signs are at the end of the text. - while escapedText[-trailingPoundCount -1] == "#": + # Ignore starting pound signs when calculating trailing signs + while level + trailingPoundCount < len(text) and \ + escapedText[-trailingPoundCount -1] == "#": trailingPoundCount += 1 token = Token() @@ -363,7 +368,7 @@ class MarkdownTokenizer(HighlightTokenizer): spaceCount += 1 # If this list item is the first in the list, ensure the - # number of spaces preceeding the bullet point does not + # number of spaces preceding the bullet point does not # exceed three, as that would indicate a code block rather # than a bullet point list. @@ -830,15 +835,15 @@ class MarkdownTokenizer(HighlightTokenizer): markupStartCount=0, markupEndCount=0, replaceMarkupChars=False, replaceAllChars=False): """ - Tokenizes a block of text, searching for all occurrances of regex. - Occurrances are set to the given token type and added to the list of + Tokenizes a block of text, searching for all occurrences of regex. + Occurrences are set to the given token type and added to the list of tokens. The markupStartCount and markupEndCount values are used to - indicate how many markup special characters preceed and follow the + indicate how many markup special characters precede and follow the main text, respectively. For example, if the matched string is "**bold**", and markupStartCount = 2 and markupEndCount = 2, then the asterisks - preceeding and following the word "bold" will be set as opening and + preceding and following the word "bold" will be set as opening and closing markup in the token. If replaceMarkupChars is true, then the markupStartCount and diff --git a/manuskript/ui/highlighters/searchResultHighlighters/__init__.py b/manuskript/ui/highlighters/searchResultHighlighters/__init__.py new file mode 100644 index 0000000..7af0224 --- /dev/null +++ b/manuskript/ui/highlighters/searchResultHighlighters/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python +# --!-- coding: utf8 --!-- diff --git a/manuskript/ui/highlighters/searchResultHighlighters/abstractSearchResultHighlighter.py b/manuskript/ui/highlighters/searchResultHighlighters/abstractSearchResultHighlighter.py new file mode 100644 index 0000000..393c1bc --- /dev/null +++ b/manuskript/ui/highlighters/searchResultHighlighters/abstractSearchResultHighlighter.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python +# --!-- coding: utf8 --!-- + + +class abstractSearchResultHighlighter(): + """ + Interface for all classes highlighting search results on widgets. + """ + def __init__(self): + pass + + def highlightSearchResult(self, searchResult): + raise NotImplementedError diff --git a/manuskript/ui/highlighters/searchResultHighlighters/abstractSpecificSearchResultHighlighter.py b/manuskript/ui/highlighters/searchResultHighlighters/abstractSpecificSearchResultHighlighter.py new file mode 100644 index 0000000..3a310e0 --- /dev/null +++ b/manuskript/ui/highlighters/searchResultHighlighters/abstractSpecificSearchResultHighlighter.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python +# --!-- coding: utf8 --!-- + + +from manuskript.ui.highlighters.searchResultHighlighters.widgetSelectionHighlighter import widgetSelectionHighlighter + + +class abstractSearchResultHighlighter(): + def __init__(self): + self._widgetSelectionHighlighter = widgetSelectionHighlighter() + + def highlightSearchResult(self, searchResult): + self.openView(searchResult) + widgets = self.retrieveWidget(searchResult) + if not isinstance(widgets, list): + widgets = [widgets] + for i in range(len(widgets)): + self._widgetSelectionHighlighter.highlight_widget_selection(widgets[i], searchResult.pos()[i][0], searchResult.pos()[i][1], i == len(widgets) - 1) + + def openView(self, searchResult): + raise RuntimeError + + def retrieveWidget(self, searchResult): + raise RuntimeError diff --git a/manuskript/ui/highlighters/searchResultHighlighters/characterSearchResultHighlighter.py b/manuskript/ui/highlighters/searchResultHighlighters/characterSearchResultHighlighter.py new file mode 100644 index 0000000..16a200d --- /dev/null +++ b/manuskript/ui/highlighters/searchResultHighlighters/characterSearchResultHighlighter.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python +# --!-- coding: utf8 --!-- + + +from manuskript.models import references as Ref +from manuskript.functions import mainWindow +from manuskript.enums import Character +from PyQt5.QtWidgets import QTextEdit, QTableView, QLineEdit +from manuskript.ui.highlighters.searchResultHighlighters.abstractSpecificSearchResultHighlighter import abstractSearchResultHighlighter + + +class characterSearchResultHighlighter(abstractSearchResultHighlighter): + def __init__(self): + super().__init__() + + def openView(self, searchResult): + r = Ref.characterReference(searchResult.id()) + Ref.open(r) + mainWindow().tabPersos.setEnabled(True) + + def retrieveWidget(self, searchResult): + textEditMap = { + Character.name: (0, "txtPersoName", QLineEdit), + Character.goal: (0, "txtPersoGoal", QTextEdit), + Character.motivation: (0, "txtPersoMotivation", QTextEdit), + Character.conflict: (0, "txtPersoConflict", QTextEdit), + Character.epiphany: (0, "txtPersoEpiphany", QTextEdit), + Character.summarySentence: (0, "txtPersoSummarySentence", QTextEdit), + Character.summaryPara: (0, "txtPersoSummaryPara", QTextEdit), + Character.summaryFull: (1, "txtPersoSummaryFull", QTextEdit), + Character.notes: (2, "txtPersoNotes", QTextEdit), + Character.infos: (3, "tblPersoInfos", QTableView) + } + + characterTabIndex, characterWidgetName, characterWidgetClass = textEditMap[searchResult.column()] + + mainWindow().tabPersos.setCurrentIndex(characterTabIndex) + return mainWindow().tabPersos.findChild(characterWidgetClass, characterWidgetName) diff --git a/manuskript/ui/highlighters/searchResultHighlighters/flatDataSearchResultHighlighter.py b/manuskript/ui/highlighters/searchResultHighlighters/flatDataSearchResultHighlighter.py new file mode 100644 index 0000000..4d68fc9 --- /dev/null +++ b/manuskript/ui/highlighters/searchResultHighlighters/flatDataSearchResultHighlighter.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python +# --!-- coding: utf8 --!-- + +from manuskript.functions import mainWindow +from manuskript.enums import FlatData +from PyQt5.QtWidgets import QTextEdit, QLineEdit +from manuskript.ui.highlighters.searchResultHighlighters.abstractSpecificSearchResultHighlighter import abstractSearchResultHighlighter + + +class flatDataSearchResultHighlighter(abstractSearchResultHighlighter): + def __init__(self): + super().__init__() + + def openView(self, searchResult): + mainWindow().tabMain.setCurrentIndex(mainWindow().TabSummary) + + def retrieveWidget(self, searchResult): + editors = { + FlatData.summarySituation: (0, "txtSummarySituation", QLineEdit, mainWindow()), + FlatData.summarySentence: (0, "txtSummarySentence", QTextEdit, mainWindow().tabSummary), + FlatData.summaryPara: (1, "txtSummaryPara", QTextEdit, mainWindow().tabSummary), + FlatData.summaryPage: (2, "txtSummaryPage", QTextEdit, mainWindow().tabSummary), + FlatData.summaryFull: (3, "txtSummaryFull", QTextEdit, mainWindow().tabSummary) + } + + stackIndex, editorName, editorClass, rootWidget = editors[searchResult.column()] + + mainWindow().tabSummary.setCurrentIndex(stackIndex) + return rootWidget.findChild(editorClass, editorName) diff --git a/manuskript/ui/highlighters/searchResultHighlighters/outlineSearchResultHighlighter.py b/manuskript/ui/highlighters/searchResultHighlighters/outlineSearchResultHighlighter.py new file mode 100644 index 0000000..801f7cd --- /dev/null +++ b/manuskript/ui/highlighters/searchResultHighlighters/outlineSearchResultHighlighter.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python +# --!-- coding: utf8 --!-- + +from manuskript.models import references as Ref +from manuskript.enums import Outline +from manuskript.ui.highlighters.searchResultHighlighters.abstractSpecificSearchResultHighlighter import abstractSearchResultHighlighter +from manuskript.functions import mainWindow +from PyQt5.QtWidgets import QTextEdit, QLineEdit, QLabel +from manuskript.ui.views.metadataView import metadataView +from manuskript.ui.collapsibleGroupBox2 import collapsibleGroupBox2 + + +class outlineSearchResultHighlighter(abstractSearchResultHighlighter): + def __init__(self): + super().__init__() + self.outline_index = None + + def openView(self, searchResult): + r = Ref.textReference(searchResult.id()) + Ref.open(r) + + def retrieveWidget(self, searchResult): + editors = { + Outline.text: ("txtRedacText", QTextEdit, None), + Outline.title: ("txtTitle", QLineEdit, "grpProperties"), + Outline.summarySentence: ("txtSummarySentence", QLineEdit, "grpSummary"), + Outline.summaryFull: ("txtSummaryFull", QTextEdit, "grpSummary"), + Outline.notes: ("txtNotes", QTextEdit, "grpNotes"), + + # TODO: Tried to highlight the combo box themselves (ie. cmbPOV) but didn't succeed. + Outline.POV: ("lblPOV", QLabel, "grpProperties"), + Outline.status: ("lblStatus", QLabel, "grpProperties"), + Outline.label: ("lblLabel", QLabel, "grpProperties") + } + + editorName, editorClass, parentName = editors[searchResult.column()] + + # Metadata columns are inside a splitter widget that my be hidden, so we show them. + if parentName: + metadataViewWidget = mainWindow().findChild(metadataView, "redacMetadata") + metadataViewWidget.show() + metadataViewWidget.findChild(collapsibleGroupBox2, parentName).button.setChecked(True) + widget = metadataViewWidget.findChild(editorClass, editorName) + else: + widget = mainWindow().mainEditor.currentEditor().findChild(editorClass, editorName) + + return widget diff --git a/manuskript/ui/highlighters/searchResultHighlighters/plotSearchResultHighlighter.py b/manuskript/ui/highlighters/searchResultHighlighters/plotSearchResultHighlighter.py new file mode 100644 index 0000000..94578a3 --- /dev/null +++ b/manuskript/ui/highlighters/searchResultHighlighters/plotSearchResultHighlighter.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python +# --!-- coding: utf8 --!-- + + +from manuskript.models import references as Ref +from manuskript.functions import mainWindow +from manuskript.enums import Plot +from PyQt5.QtWidgets import QTextEdit, QLineEdit, QListView +from manuskript.ui.highlighters.searchResultHighlighters.abstractSpecificSearchResultHighlighter import abstractSearchResultHighlighter + + +class plotSearchResultHighlighter(abstractSearchResultHighlighter): + def __init__(self): + super().__init__() + + def openView(self, searchResult): + r = Ref.plotReference(searchResult.id()) + Ref.open(r) + mainWindow().tabPlot.setEnabled(True) + + def retrieveWidget(self, searchResult): + textEditMap = { + Plot.name: (0, "txtPlotName", QLineEdit), + Plot.description: (0, "txtPlotDescription", QTextEdit), + Plot.characters: (0, "lstPlotPerso", QListView), + Plot.result: (0, "txtPlotResult", QTextEdit) + } + + tabIndex, widgetName, widgetClass = textEditMap[searchResult.column()] + + mainWindow().tabPlot.setCurrentIndex(tabIndex) + return mainWindow().tabPlot.findChild(widgetClass, widgetName) diff --git a/manuskript/ui/highlighters/searchResultHighlighters/plotStepSearchResultHighlighter.py b/manuskript/ui/highlighters/searchResultHighlighters/plotStepSearchResultHighlighter.py new file mode 100644 index 0000000..7b7b146 --- /dev/null +++ b/manuskript/ui/highlighters/searchResultHighlighters/plotStepSearchResultHighlighter.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python +# --!-- coding: utf8 --!-- + + +from manuskript.models import references as Ref +from manuskript.functions import mainWindow +from manuskript.enums import PlotStep +from PyQt5.QtWidgets import QTableView, QTextEdit +from manuskript.ui.highlighters.searchResultHighlighters.abstractSpecificSearchResultHighlighter import abstractSearchResultHighlighter + + +class plotStepSearchResultHighlighter(abstractSearchResultHighlighter): + def __init__(self): + super().__init__() + + def openView(self, searchResult): + r = Ref.plotReference(searchResult.id()) + Ref.open(r) + mainWindow().tabPlot.setEnabled(True) + + def retrieveWidget(self, searchResult): + textEditMap = { + PlotStep.name: [(1, "lstSubPlots", QTableView)], + PlotStep.meta: [(1, "lstSubPlots", QTableView)], + PlotStep.summary: [(1, "lstSubPlots", QTableView), (1, "txtSubPlotSummary", QTextEdit)] + } + + map = textEditMap[searchResult.column()] + widgets = [] + for tabIndex, widgetName, widgetClass in map: + mainWindow().tabPlot.setCurrentIndex(tabIndex) + + widgets.append(mainWindow().tabPlot.findChild(widgetClass, widgetName)) + + return widgets diff --git a/manuskript/ui/highlighters/searchResultHighlighters/searchResultHighlighter.py b/manuskript/ui/highlighters/searchResultHighlighters/searchResultHighlighter.py new file mode 100644 index 0000000..eeb1aa8 --- /dev/null +++ b/manuskript/ui/highlighters/searchResultHighlighters/searchResultHighlighter.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python +# --!-- coding: utf8 --!-- + +from manuskript.ui.highlighters.searchResultHighlighters.abstractSearchResultHighlighter import abstractSearchResultHighlighter +from manuskript.ui.highlighters.searchResultHighlighters.characterSearchResultHighlighter import characterSearchResultHighlighter +from manuskript.ui.highlighters.searchResultHighlighters.flatDataSearchResultHighlighter import flatDataSearchResultHighlighter +from manuskript.ui.highlighters.searchResultHighlighters.outlineSearchResultHighlighter import outlineSearchResultHighlighter +from manuskript.ui.highlighters.searchResultHighlighters.worldSearchResultHighlighter import worldSearchResultHighlighter +from manuskript.ui.highlighters.searchResultHighlighters.plotSearchResultHighlighter import plotSearchResultHighlighter +from manuskript.ui.highlighters.searchResultHighlighters.plotStepSearchResultHighlighter import plotStepSearchResultHighlighter +from manuskript.enums import Model + + +class searchResultHighlighter(abstractSearchResultHighlighter): + def __init__(self): + super().__init__() + + def highlightSearchResult(self, searchResult): + if searchResult.type() == Model.Character: + highlighter = characterSearchResultHighlighter() + elif searchResult.type() == Model.FlatData: + highlighter = flatDataSearchResultHighlighter() + elif searchResult.type() == Model.Outline: + highlighter = outlineSearchResultHighlighter() + elif searchResult.type() == Model.World: + highlighter = worldSearchResultHighlighter() + elif searchResult.type() == Model.Plot: + highlighter = plotSearchResultHighlighter() + elif searchResult.type() == Model.PlotStep: + highlighter = plotStepSearchResultHighlighter() + else: + raise NotImplementedError + + highlighter.highlightSearchResult(searchResult) diff --git a/manuskript/ui/highlighters/searchResultHighlighters/widgetSelectionHighlighter.py b/manuskript/ui/highlighters/searchResultHighlighters/widgetSelectionHighlighter.py new file mode 100644 index 0000000..1533387 --- /dev/null +++ b/manuskript/ui/highlighters/searchResultHighlighters/widgetSelectionHighlighter.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python +# --!-- coding: utf8 --!-- + +from PyQt5.QtGui import QTextCursor +from PyQt5.QtWidgets import QTextEdit, QTableView, QListView, QLineEdit, QPlainTextEdit, QLabel + + +class widgetSelectionHighlighter(): + """ + Utility class for highlighting a search result on a widget. + """ + def __init__(self): + pass + + def highlight_widget_selection(self, widget, startPos, endPos, clearOnFocusOut=True): + if isinstance(widget, QTextEdit) or isinstance(widget, QPlainTextEdit): + self._highlightTextEditSearchResult(widget, startPos, endPos, clearOnFocusOut) + elif isinstance(widget, QLineEdit): + self._highlightLineEditSearchResult(widget, startPos, endPos, clearOnFocusOut) + elif isinstance(widget, QTableView): + self._highlightTableViewSearchResult(widget, startPos, clearOnFocusOut) + elif isinstance(widget, QListView): + self._highlightListViewSearchResult(widget, startPos, clearOnFocusOut) + elif isinstance(widget, QLabel): + self._highlightLabelSearchResult(widget, clearOnFocusOut) + else: + raise NotImplementedError + + widget.setFocus(True) + + @staticmethod + def generateClearHandler(widget, clearCallback): + """ + Generates a clear handler to be run when the given widget loses focus. + + :param widget: widget we want to attach the handler to + :param clearCallback: callback to be called when the given widget loses focus. + :return: + """ + def clearHandler(_widget, previous_on_focus_out_event): + clearCallback(_widget) + _widget.focusOutEvent = previous_on_focus_out_event + + widget.focusOutEvent = lambda e: clearHandler(widget, widget.focusOutEvent) + + def _highlightTextEditSearchResult(self, textEdit, startPos, endPos, clearOnFocusOut): + # On focus out, clear text edit selection. + oldTextCursor = textEdit.textCursor() + if clearOnFocusOut: + self.generateClearHandler(textEdit, lambda widget: widget.setTextCursor(oldTextCursor)) + + # Highlight search result on the text edit. + c = textEdit.textCursor() + c.setPosition(startPos) + c.setPosition(endPos, QTextCursor.KeepAnchor) + textEdit.setTextCursor(c) + + def _highlightLineEditSearchResult(self, lineEdit, startPos, endPos, clearOnFocusOut): + # On focus out, clear line edit selection. + if clearOnFocusOut: + self.generateClearHandler(lineEdit, lambda widget: widget.deselect()) + + # Highlight search result on line edit. + lineEdit.setCursorPosition(startPos) + lineEdit.cursorForward(True, endPos - startPos) + + def _highlightTableViewSearchResult(self, tableView, startPos, clearOnFocusOut): + # On focus out, clear table selection. + if clearOnFocusOut: + self.generateClearHandler(tableView, lambda widget: widget.clearSelection()) + + # Highlight table row containing search result. + tableView.selectRow(startPos) + + def _highlightListViewSearchResult(self, listView, startPos, clearOnFocusOut): + # On focus out, clear table selection. + if clearOnFocusOut: + self.generateClearHandler(listView, lambda widget: widget.selectionModel().clearSelection()) + + # Highlight list item containing search result. + listView.setCurrentIndex(listView.model().index(startPos, 0, listView.rootIndex())) + + def _highlightLabelSearchResult(self, label, clearOnFocusOut): + # On focus out, clear label selection. + # FIXME: This would overwrite all styles! + oldStyle = label.styleSheet() + if clearOnFocusOut: + self.generateClearHandler(label, lambda widget: widget.setStyleSheet(oldStyle)) + + # Highlight search result on label. + label.setStyleSheet("background-color: steelblue") diff --git a/manuskript/ui/highlighters/searchResultHighlighters/worldSearchResultHighlighter.py b/manuskript/ui/highlighters/searchResultHighlighters/worldSearchResultHighlighter.py new file mode 100644 index 0000000..0556b0c --- /dev/null +++ b/manuskript/ui/highlighters/searchResultHighlighters/worldSearchResultHighlighter.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python +# --!-- coding: utf8 --!-- + + +from manuskript.models import references as Ref +from manuskript.functions import mainWindow +from manuskript.enums import World +from PyQt5.QtWidgets import QTextEdit, QLineEdit +from manuskript.ui.highlighters.searchResultHighlighters.abstractSpecificSearchResultHighlighter import abstractSearchResultHighlighter + + +class worldSearchResultHighlighter(abstractSearchResultHighlighter): + def __init__(self): + super().__init__() + + def openView(self, searchResult): + r = Ref.worldReference(searchResult.id()) + Ref.open(r) + mainWindow().tabWorld.setEnabled(True) + + def retrieveWidget(self, searchResult): + textEditMap = { + World.name: (0, "txtWorldName", QLineEdit), + World.description: (0, "txtWorldDescription", QTextEdit), + World.passion: (1, "txtWorldPassion", QTextEdit), + World.conflict: (1, "txtWorldConflict", QTextEdit), + } + + tabIndex, widgetName, widgetClass = textEditMap[searchResult.column()] + + mainWindow().tabWorld.setCurrentIndex(tabIndex) + return mainWindow().tabWorld.findChild(widgetClass, widgetName) diff --git a/manuskript/ui/importers/importer.py b/manuskript/ui/importers/importer.py index 287ea70..194c2cd 100644 --- a/manuskript/ui/importers/importer.py +++ b/manuskript/ui/importers/importer.py @@ -122,9 +122,8 @@ class importerDialog(QWidget, Ui_importer): F = self._format options = QFileDialog.Options() - options |= QFileDialog.DontUseNativeDialog if F.fileFormat == "<>": - options = QFileDialog.DontUseNativeDialog | QFileDialog.ShowDirsOnly + options = QFileDialog.ShowDirsOnly fileName = QFileDialog.getExistingDirectory(self, "Select import folder", "", options=options) else: @@ -196,8 +195,8 @@ class importerDialog(QWidget, Ui_importer): self.grpPreview.setEnabled(True) self.settingsWidget = generalSettings() - #TODO: custom format widget - # self.settingsWidget = F.settingsWidget(self.settingsWidget) + #TODO: custom format widget to match exporter visuals? + self.settingsWidget = F.settingsWidget(self.settingsWidget) # Set the settings widget in place self.setGroupWidget(self.grpSettings, self.settingsWidget) diff --git a/manuskript/ui/importers/importer_ui.py b/manuskript/ui/importers/importer_ui.py index d23c8e4..59c8a6b 100644 --- a/manuskript/ui/importers/importer_ui.py +++ b/manuskript/ui/importers/importer_ui.py @@ -97,7 +97,7 @@ class Ui_importer(object): _translate = QtCore.QCoreApplication.translate importer.setWindowTitle(_translate("importer", "Import")) self.label.setText(_translate("importer", "Format:")) - self.btnChoseFile.setText(_translate("importer", "Chose file")) + self.btnChoseFile.setText(_translate("importer", "Choose file")) self.btnClearFileName.setToolTip(_translate("importer", "Clear file")) self.btnPreview.setText(_translate("importer", "Preview")) self.btnImport.setText(_translate("importer", "Import")) diff --git a/manuskript/ui/importers/importer_ui.ui b/manuskript/ui/importers/importer_ui.ui index cbf72b9..98ca1df 100644 --- a/manuskript/ui/importers/importer_ui.ui +++ b/manuskript/ui/importers/importer_ui.ui @@ -42,7 +42,7 @@ - +
- Chose file +Choose file diff --git a/manuskript/ui/listDialog.py b/manuskript/ui/listDialog.py new file mode 100644 index 0000000..e3941da --- /dev/null +++ b/manuskript/ui/listDialog.py @@ -0,0 +1,16 @@ +from PyQt5.QtWidgets import QDialog +from manuskript.ui.listDialog_ui import Ui_GenericListDialog + + +class ListDialog(QDialog, Ui_GenericListDialog): + def __init__(self, parent=None): + QDialog.__init__(self, parent) + self.setupUi(self) + + def accept(self): + self.hide() + self.close() + + def reject(self): + self.hide() + self.close() diff --git a/manuskript/ui/listDialog_ui.py b/manuskript/ui/listDialog_ui.py new file mode 100644 index 0000000..17620be --- /dev/null +++ b/manuskript/ui/listDialog_ui.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'listDialog_ui.ui' +# +# Created by: PyQt5 UI code generator 5.12.3 +# +# WARNING! All changes made in this file will be lost! + + +from PyQt5 import QtCore, QtGui, QtWidgets + + +class Ui_GenericListDialog(object): + def setupUi(self, GenericListDialog): + GenericListDialog.setObjectName("GenericListDialog") + GenericListDialog.resize(451, 340) + GenericListDialog.setModal(False) + self.verticalLayout_2 = QtWidgets.QVBoxLayout(GenericListDialog) + self.verticalLayout_2.setObjectName("verticalLayout_2") + self.verticalLayout = QtWidgets.QVBoxLayout() + self.verticalLayout.setObjectName("verticalLayout") + self.label = QtWidgets.QLabel(GenericListDialog) + self.label.setObjectName("label") + self.verticalLayout.addWidget(self.label) + self.listWidget = QtWidgets.QListWidget(GenericListDialog) + self.listWidget.setObjectName("listWidget") + self.verticalLayout.addWidget(self.listWidget) + self.verticalLayout_2.addLayout(self.verticalLayout) + self.buttonBox = QtWidgets.QDialogButtonBox(GenericListDialog) + self.buttonBox.setOrientation(QtCore.Qt.Horizontal) + self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.Ok) + self.buttonBox.setObjectName("buttonBox") + self.verticalLayout_2.addWidget(self.buttonBox) + + self.retranslateUi(GenericListDialog) + self.buttonBox.accepted.connect(GenericListDialog.accept) + self.buttonBox.rejected.connect(GenericListDialog.reject) + QtCore.QMetaObject.connectSlotsByName(GenericListDialog) + + def retranslateUi(self, GenericListDialog): + _translate = QtCore.QCoreApplication.translate + GenericListDialog.setWindowTitle(_translate("GenericListDialog", "Title")) + self.label.setText(_translate("GenericListDialog", "Text")) + + +if __name__ == "__main__": + import sys + app = QtWidgets.QApplication(sys.argv) + GenericListDialog = QtWidgets.QDialog() + ui = Ui_GenericListDialog() + ui.setupUi(GenericListDialog) + GenericListDialog.show() + sys.exit(app.exec_()) diff --git a/manuskript/ui/listDialog_ui.ui b/manuskript/ui/listDialog_ui.ui new file mode 100644 index 0000000..b41a257 --- /dev/null +++ b/manuskript/ui/listDialog_ui.ui @@ -0,0 +1,81 @@ + + + diff --git a/manuskript/ui/mainWindow.py b/manuskript/ui/mainWindow.py index d369527..1776d29 100644 --- a/manuskript/ui/mainWindow.py +++ b/manuskript/ui/mainWindow.py @@ -2,12 +2,15 @@ # Form implementation generated from reading ui file 'manuskript/ui/mainWindow.ui' # -# Created by: PyQt5 UI code generator 5.11.3 +# Created by: PyQt5 UI code generator 5.15.7 # -# WARNING! All changes made in this file will be lost! +# WARNING: Any manual changes made to this file will be lost when pyuic5 is +# run again. Do not edit this file unless you know what you are doing. + from PyQt5 import QtCore, QtGui, QtWidgets + class Ui_MainWindow(object): def setupUi(self, MainWindow): MainWindow.setObjectName("MainWindow") @@ -25,17 +28,17 @@ class Ui_MainWindow(object): self.welcomePage.setObjectName("welcomePage") self.gridLayout = QtWidgets.QGridLayout(self.welcomePage) self.gridLayout.setObjectName("gridLayout") - spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Minimum) self.gridLayout.addItem(spacerItem, 1, 0, 1, 1) - spacerItem1 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + spacerItem1 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Minimum) self.gridLayout.addItem(spacerItem1, 1, 2, 1, 1) self.welcome = welcome(self.welcomePage) self.welcome.setMinimumSize(QtCore.QSize(200, 200)) self.welcome.setObjectName("welcome") self.gridLayout.addWidget(self.welcome, 1, 1, 1, 1) - spacerItem2 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + spacerItem2 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Minimum) self.gridLayout.addItem(spacerItem2, 0, 1, 1, 1) - spacerItem3 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + spacerItem3 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Minimum) self.gridLayout.addItem(spacerItem3, 2, 1, 1, 1) self.stack.addWidget(self.welcomePage) self.page_4 = QtWidgets.QWidget() @@ -134,14 +137,18 @@ class Ui_MainWindow(object): self.label_9 = QtWidgets.QLabel(self.lytTabSummary) font = QtGui.QFont() font.setBold(True) - font.setWeight(75) self.label_9.setFont(font) self.label_9.setObjectName("label_9") self.formLayout_5.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.label_9) + self.lytSituation = QtWidgets.QVBoxLayout() + self.lytSituation.setObjectName("lytSituation") + self.txtSummarySituation = lineEditView(self.lytTabSummary) + self.txtSummarySituation.setObjectName("txtSummarySituation") + self.lytSituation.addWidget(self.txtSummarySituation) + self.formLayout_5.setLayout(0, QtWidgets.QFormLayout.FieldRole, self.lytSituation) self.label_29 = QtWidgets.QLabel(self.lytTabSummary) font = QtGui.QFont() font.setBold(True) - font.setWeight(75) self.label_29.setFont(font) self.label_29.setObjectName("label_29") self.formLayout_5.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.label_29) @@ -290,37 +297,6 @@ class Ui_MainWindow(object): self.horizontalLayout_8.addLayout(self.verticalLayout_4) self.tabSummary.addWidget(self.tabSummaryPage4) self.formLayout_5.setWidget(2, QtWidgets.QFormLayout.FieldRole, self.tabSummary) - self.horizontalLayout_5 = QtWidgets.QHBoxLayout() - self.horizontalLayout_5.setObjectName("horizontalLayout_5") - spacerItem9 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.horizontalLayout_5.addItem(spacerItem9) - self.btnStepThree = QtWidgets.QPushButton(self.lytTabSummary) - icon = QtGui.QIcon.fromTheme("go-next") - self.btnStepThree.setIcon(icon) - self.btnStepThree.setObjectName("btnStepThree") - self.horizontalLayout_5.addWidget(self.btnStepThree) - self.btnStepTwo = QtWidgets.QPushButton(self.lytTabSummary) - icon = QtGui.QIcon.fromTheme("go-next") - self.btnStepTwo.setIcon(icon) - self.btnStepTwo.setObjectName("btnStepTwo") - self.horizontalLayout_5.addWidget(self.btnStepTwo) - self.btnStepFive = QtWidgets.QPushButton(self.lytTabSummary) - icon = QtGui.QIcon.fromTheme("go-next") - self.btnStepFive.setIcon(icon) - self.btnStepFive.setObjectName("btnStepFive") - self.horizontalLayout_5.addWidget(self.btnStepFive) - self.btnStepSeven = QtWidgets.QPushButton(self.lytTabSummary) - icon = QtGui.QIcon.fromTheme("go-next") - self.btnStepSeven.setIcon(icon) - self.btnStepSeven.setObjectName("btnStepSeven") - self.horizontalLayout_5.addWidget(self.btnStepSeven) - self.formLayout_5.setLayout(3, QtWidgets.QFormLayout.FieldRole, self.horizontalLayout_5) - self.lytSituation = QtWidgets.QVBoxLayout() - self.lytSituation.setObjectName("lytSituation") - self.txtSummarySituation = lineEditView(self.lytTabSummary) - self.txtSummarySituation.setObjectName("txtSummarySituation") - self.lytSituation.addWidget(self.txtSummarySituation) - self.formLayout_5.setLayout(0, QtWidgets.QFormLayout.FieldRole, self.lytSituation) self.verticalLayout_24.addLayout(self.formLayout_5) self.tabMain.addTab(self.lytTabSummary, "") self.lytTabPersos = QtWidgets.QWidget() @@ -378,69 +354,11 @@ class Ui_MainWindow(object): self.scrollAreaPersoInfos.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop) self.scrollAreaPersoInfos.setObjectName("scrollAreaPersoInfos") self.scrollAreaPersoInfosWidget = QtWidgets.QWidget() - self.scrollAreaPersoInfosWidget.setGeometry(QtCore.QRect(0, 0, 252, 664)) + self.scrollAreaPersoInfosWidget.setGeometry(QtCore.QRect(0, 0, 453, 699)) self.scrollAreaPersoInfosWidget.setObjectName("scrollAreaPersoInfosWidget") self.formLayout_8 = QtWidgets.QFormLayout(self.scrollAreaPersoInfosWidget) self.formLayout_8.setFieldGrowthPolicy(QtWidgets.QFormLayout.AllNonFixedFieldsGrow) self.formLayout_8.setObjectName("formLayout_8") - self.label_4 = QtWidgets.QLabel(self.scrollAreaPersoInfosWidget) - self.label_4.setObjectName("label_4") - self.formLayout_8.setWidget(4, QtWidgets.QFormLayout.LabelRole, self.label_4) - self.txtPersoMotivation = MDEditCompleter(self.scrollAreaPersoInfosWidget) - self.txtPersoMotivation.setObjectName("txtPersoMotivation") - self.formLayout_8.setWidget(4, QtWidgets.QFormLayout.FieldRole, self.txtPersoMotivation) - self.label_5 = QtWidgets.QLabel(self.scrollAreaPersoInfosWidget) - self.label_5.setObjectName("label_5") - self.formLayout_8.setWidget(5, QtWidgets.QFormLayout.LabelRole, self.label_5) - self.txtPersoGoal = MDEditCompleter(self.scrollAreaPersoInfosWidget) - self.txtPersoGoal.setObjectName("txtPersoGoal") - self.formLayout_8.setWidget(5, QtWidgets.QFormLayout.FieldRole, self.txtPersoGoal) - self.label_6 = QtWidgets.QLabel(self.scrollAreaPersoInfosWidget) - self.label_6.setObjectName("label_6") - self.formLayout_8.setWidget(6, QtWidgets.QFormLayout.LabelRole, self.label_6) - self.txtPersoConflict = MDEditCompleter(self.scrollAreaPersoInfosWidget) - self.txtPersoConflict.setObjectName("txtPersoConflict") - self.formLayout_8.setWidget(6, QtWidgets.QFormLayout.FieldRole, self.txtPersoConflict) - self.label_7 = QtWidgets.QLabel(self.scrollAreaPersoInfosWidget) - self.label_7.setObjectName("label_7") - self.formLayout_8.setWidget(7, QtWidgets.QFormLayout.LabelRole, self.label_7) - self.txtPersoEpiphany = MDEditCompleter(self.scrollAreaPersoInfosWidget) - self.txtPersoEpiphany.setObjectName("txtPersoEpiphany") - self.formLayout_8.setWidget(7, QtWidgets.QFormLayout.FieldRole, self.txtPersoEpiphany) - self.label_24 = QtWidgets.QLabel(self.scrollAreaPersoInfosWidget) - self.label_24.setObjectName("label_24") - self.formLayout_8.setWidget(8, QtWidgets.QFormLayout.LabelRole, self.label_24) - self.txtPersoSummarySentence = MDEditCompleter(self.scrollAreaPersoInfosWidget) - self.txtPersoSummarySentence.setObjectName("txtPersoSummarySentence") - self.formLayout_8.setWidget(8, QtWidgets.QFormLayout.FieldRole, self.txtPersoSummarySentence) - self.label_8 = QtWidgets.QLabel(self.scrollAreaPersoInfosWidget) - self.label_8.setObjectName("label_8") - self.formLayout_8.setWidget(9, QtWidgets.QFormLayout.LabelRole, self.label_8) - self.txtPersoSummaryPara = MDEditCompleter(self.scrollAreaPersoInfosWidget) - self.txtPersoSummaryPara.setObjectName("txtPersoSummaryPara") - self.formLayout_8.setWidget(9, QtWidgets.QFormLayout.FieldRole, self.txtPersoSummaryPara) - self.horizontalLayout_21 = QtWidgets.QHBoxLayout() - self.horizontalLayout_21.setObjectName("horizontalLayout_21") - spacerItem10 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.horizontalLayout_21.addItem(spacerItem10) - self.btnStepFour = QtWidgets.QPushButton(self.scrollAreaPersoInfosWidget) - icon = QtGui.QIcon.fromTheme("go-next") - self.btnStepFour.setIcon(icon) - self.btnStepFour.setFlat(True) - self.btnStepFour.setObjectName("btnStepFour") - self.horizontalLayout_21.addWidget(self.btnStepFour) - self.formLayout_8.setLayout(10, QtWidgets.QFormLayout.FieldRole, self.horizontalLayout_21) - self.label_18 = QtWidgets.QLabel(self.scrollAreaPersoInfosWidget) - self.label_18.setObjectName("label_18") - self.formLayout_8.setWidget(3, QtWidgets.QFormLayout.LabelRole, self.label_18) - self.sldPersoImportance = sldImportance(self.scrollAreaPersoInfosWidget) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.sldPersoImportance.sizePolicy().hasHeightForWidth()) - self.sldPersoImportance.setSizePolicy(sizePolicy) - self.sldPersoImportance.setObjectName("sldPersoImportance") - self.formLayout_8.setWidget(3, QtWidgets.QFormLayout.FieldRole, self.sldPersoImportance) self.label_3 = QtWidgets.QLabel(self.scrollAreaPersoInfosWidget) self.label_3.setObjectName("label_3") self.formLayout_8.setWidget(2, QtWidgets.QFormLayout.LabelRole, self.label_3) @@ -454,6 +372,62 @@ class Ui_MainWindow(object): self.btnPersoColor.setObjectName("btnPersoColor") self.horizontalLayout_3.addWidget(self.btnPersoColor) self.formLayout_8.setLayout(2, QtWidgets.QFormLayout.FieldRole, self.horizontalLayout_3) + self.label_18 = QtWidgets.QLabel(self.scrollAreaPersoInfosWidget) + self.label_18.setObjectName("label_18") + self.formLayout_8.setWidget(4, QtWidgets.QFormLayout.LabelRole, self.label_18) + self.horizontalLayout_20 = QtWidgets.QHBoxLayout() + self.horizontalLayout_20.setObjectName("horizontalLayout_20") + self.sldPersoImportance = sldImportance(self.scrollAreaPersoInfosWidget) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.sldPersoImportance.sizePolicy().hasHeightForWidth()) + self.sldPersoImportance.setSizePolicy(sizePolicy) + self.sldPersoImportance.setObjectName("sldPersoImportance") + self.horizontalLayout_20.addWidget(self.sldPersoImportance) + self.chkPersoPOV = QtWidgets.QCheckBox(self.scrollAreaPersoInfosWidget) + self.chkPersoPOV.setChecked(False) + self.chkPersoPOV.setAutoRepeat(False) + self.chkPersoPOV.setTristate(False) + self.chkPersoPOV.setObjectName("chkPersoPOV") + self.horizontalLayout_20.addWidget(self.chkPersoPOV) + self.formLayout_8.setLayout(4, QtWidgets.QFormLayout.FieldRole, self.horizontalLayout_20) + self.label_4 = QtWidgets.QLabel(self.scrollAreaPersoInfosWidget) + self.label_4.setObjectName("label_4") + self.formLayout_8.setWidget(6, QtWidgets.QFormLayout.LabelRole, self.label_4) + self.txtPersoMotivation = MDEditCompleter(self.scrollAreaPersoInfosWidget) + self.txtPersoMotivation.setObjectName("txtPersoMotivation") + self.formLayout_8.setWidget(6, QtWidgets.QFormLayout.FieldRole, self.txtPersoMotivation) + self.label_5 = QtWidgets.QLabel(self.scrollAreaPersoInfosWidget) + self.label_5.setObjectName("label_5") + self.formLayout_8.setWidget(7, QtWidgets.QFormLayout.LabelRole, self.label_5) + self.txtPersoGoal = MDEditCompleter(self.scrollAreaPersoInfosWidget) + self.txtPersoGoal.setObjectName("txtPersoGoal") + self.formLayout_8.setWidget(7, QtWidgets.QFormLayout.FieldRole, self.txtPersoGoal) + self.label_6 = QtWidgets.QLabel(self.scrollAreaPersoInfosWidget) + self.label_6.setObjectName("label_6") + self.formLayout_8.setWidget(8, QtWidgets.QFormLayout.LabelRole, self.label_6) + self.txtPersoConflict = MDEditCompleter(self.scrollAreaPersoInfosWidget) + self.txtPersoConflict.setObjectName("txtPersoConflict") + self.formLayout_8.setWidget(8, QtWidgets.QFormLayout.FieldRole, self.txtPersoConflict) + self.label_7 = QtWidgets.QLabel(self.scrollAreaPersoInfosWidget) + self.label_7.setObjectName("label_7") + self.formLayout_8.setWidget(9, QtWidgets.QFormLayout.LabelRole, self.label_7) + self.txtPersoEpiphany = MDEditCompleter(self.scrollAreaPersoInfosWidget) + self.txtPersoEpiphany.setObjectName("txtPersoEpiphany") + self.formLayout_8.setWidget(9, QtWidgets.QFormLayout.FieldRole, self.txtPersoEpiphany) + self.label_24 = QtWidgets.QLabel(self.scrollAreaPersoInfosWidget) + self.label_24.setObjectName("label_24") + self.formLayout_8.setWidget(10, QtWidgets.QFormLayout.LabelRole, self.label_24) + self.txtPersoSummarySentence = MDEditCompleter(self.scrollAreaPersoInfosWidget) + self.txtPersoSummarySentence.setObjectName("txtPersoSummarySentence") + self.formLayout_8.setWidget(10, QtWidgets.QFormLayout.FieldRole, self.txtPersoSummarySentence) + self.label_8 = QtWidgets.QLabel(self.scrollAreaPersoInfosWidget) + self.label_8.setObjectName("label_8") + self.formLayout_8.setWidget(11, QtWidgets.QFormLayout.LabelRole, self.label_8) + self.txtPersoSummaryPara = MDEditCompleter(self.scrollAreaPersoInfosWidget) + self.txtPersoSummaryPara.setObjectName("txtPersoSummaryPara") + self.formLayout_8.setWidget(11, QtWidgets.QFormLayout.FieldRole, self.txtPersoSummaryPara) self.scrollAreaPersoInfos.setWidget(self.scrollAreaPersoInfosWidget) self.verticalLayout_20.addWidget(self.scrollAreaPersoInfos) self.tabPersos.addTab(self.info, "") @@ -464,16 +438,6 @@ class Ui_MainWindow(object): self.txtPersoSummaryFull = MDEditCompleter(self.tab_11) self.txtPersoSummaryFull.setObjectName("txtPersoSummaryFull") self.verticalLayout_17.addWidget(self.txtPersoSummaryFull) - self.horizontalLayout_22 = QtWidgets.QHBoxLayout() - self.horizontalLayout_22.setObjectName("horizontalLayout_22") - spacerItem11 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.horizontalLayout_22.addItem(spacerItem11) - self.btnStepSix = QtWidgets.QPushButton(self.tab_11) - icon = QtGui.QIcon.fromTheme("go-next") - self.btnStepSix.setIcon(icon) - self.btnStepSix.setObjectName("btnStepSix") - self.horizontalLayout_22.addWidget(self.btnStepSix) - self.verticalLayout_17.addLayout(self.horizontalLayout_22) self.tabPersos.addTab(self.tab_11, "") self.tab_19 = QtWidgets.QWidget() self.tab_19.setObjectName("tab_19") @@ -510,13 +474,8 @@ class Ui_MainWindow(object): self.lineEdit = QtWidgets.QLineEdit(self.tab_12) self.lineEdit.setObjectName("lineEdit") self.horizontalLayout_9.addWidget(self.lineEdit) - spacerItem12 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.horizontalLayout_9.addItem(spacerItem12) - self.btnStepEight = QtWidgets.QPushButton(self.tab_12) - icon = QtGui.QIcon.fromTheme("go-next") - self.btnStepEight.setIcon(icon) - self.btnStepEight.setObjectName("btnStepEight") - self.horizontalLayout_9.addWidget(self.btnStepEight) + spacerItem9 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + self.horizontalLayout_9.addItem(spacerItem9) self.verticalLayout_9.addLayout(self.horizontalLayout_9) self.tabPersos.addTab(self.tab_12, "") self.verticalLayout_25.addWidget(self.splitterPersos) @@ -599,8 +558,8 @@ class Ui_MainWindow(object): self.verticalLayout_12.addWidget(self.lstPlotPerso) self.horizontalLayout_16 = QtWidgets.QHBoxLayout() self.horizontalLayout_16.setObjectName("horizontalLayout_16") - spacerItem13 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.horizontalLayout_16.addItem(spacerItem13) + spacerItem10 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + self.horizontalLayout_16.addItem(spacerItem10) self.btnAddPlotPerso = QtWidgets.QPushButton(self.infos_2) self.btnAddPlotPerso.setText("") icon = QtGui.QIcon.fromTheme("list-add") @@ -668,8 +627,8 @@ class Ui_MainWindow(object): self.btnRmSubPlot.setFlat(True) self.btnRmSubPlot.setObjectName("btnRmSubPlot") self.horizontalLayout_17.addWidget(self.btnRmSubPlot) - spacerItem14 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.horizontalLayout_17.addItem(spacerItem14) + spacerItem11 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + self.horizontalLayout_17.addItem(spacerItem11) self.btnShowSubPlotSummary = QtWidgets.QPushButton(self.tab_15) self.btnShowSubPlotSummary.setText("") icon = QtGui.QIcon.fromTheme("text-x-generic") @@ -745,7 +704,7 @@ class Ui_MainWindow(object): self.treeWorld.setRootIsDecorated(False) self.treeWorld.setObjectName("treeWorld") self.treeWorld.header().setVisible(False) - self.treeWorld.header().setDefaultSectionSize(0) + self.treeWorld.header().setDefaultSectionSize(35) self.verticalLayout_32.addWidget(self.treeWorld) self.horizontalLayout_19 = QtWidgets.QHBoxLayout() self.horizontalLayout_19.setObjectName("horizontalLayout_19") @@ -878,8 +837,8 @@ class Ui_MainWindow(object): self.btnOutlineRemoveItem.setFlat(True) self.btnOutlineRemoveItem.setObjectName("btnOutlineRemoveItem") self.horizontalLayout_18.addWidget(self.btnOutlineRemoveItem) - spacerItem15 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.horizontalLayout_18.addItem(spacerItem15) + spacerItem12 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + self.horizontalLayout_18.addItem(spacerItem12) self.btnPlanShowDetails = QtWidgets.QPushButton(self.layoutWidget) self.btnPlanShowDetails.setText("") icon = QtGui.QIcon.fromTheme("text-x-generic") @@ -940,8 +899,8 @@ class Ui_MainWindow(object): self.btnRedacRemoveItem.setFlat(True) self.btnRedacRemoveItem.setObjectName("btnRedacRemoveItem") self.horizontalLayout_31.addWidget(self.btnRedacRemoveItem) - spacerItem16 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.horizontalLayout_31.addItem(spacerItem16) + spacerItem13 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + self.horizontalLayout_31.addItem(spacerItem13) self.verticalLayout_30.addLayout(self.horizontalLayout_31) self.mainEditor = mainEditor(self.splitterRedacH) self.mainEditor.setObjectName("mainEditor") @@ -1030,7 +989,7 @@ class Ui_MainWindow(object): self.horizontalLayout_2.addWidget(self.stack) MainWindow.setCentralWidget(self.centralwidget) self.menubar = QtWidgets.QMenuBar(MainWindow) - self.menubar.setGeometry(QtCore.QRect(0, 0, 1112, 25)) + self.menubar.setGeometry(QtCore.QRect(0, 0, 1112, 23)) self.menubar.setObjectName("menubar") self.menuFile = QtWidgets.QMenu(self.menubar) self.menuFile.setObjectName("menuFile") @@ -1070,7 +1029,6 @@ class Ui_MainWindow(object): self.cheatSheet = cheatSheet(self.dockWidgetContents_5) font = QtGui.QFont() font.setBold(False) - font.setWeight(50) self.cheatSheet.setFont(font) self.cheatSheet.setObjectName("cheatSheet") self.verticalLayout_39.addWidget(self.cheatSheet) @@ -1086,7 +1044,6 @@ class Ui_MainWindow(object): self.widget = search(self.dockWidgetContents_6) font = QtGui.QFont() font.setBold(False) - font.setWeight(50) self.widget.setFont(font) self.widget.setObjectName("widget") self.verticalLayout_40.addWidget(self.widget) @@ -1273,6 +1230,14 @@ class Ui_MainWindow(object): self.actFormatBlockquote.setObjectName("actFormatBlockquote") self.actToolTargets = QtWidgets.QAction(MainWindow) self.actToolTargets.setObjectName("actToolTargets") + self.actSearch = QtWidgets.QAction(MainWindow) + icon = QtGui.QIcon.fromTheme("edit-find") + self.actSearch.setIcon(icon) + self.actSearch.setObjectName("actSearch") + self.actSupport = QtWidgets.QAction(MainWindow) + self.actSupport.setObjectName("actSupport") + self.actLocateLog = QtWidgets.QAction(MainWindow) + self.actLocateLog.setObjectName("actLocateLog") self.menuFile.addAction(self.actOpen) self.menuFile.addAction(self.menuRecents.menuAction()) self.menuFile.addAction(self.actSave) @@ -1284,6 +1249,10 @@ class Ui_MainWindow(object): self.menuFile.addSeparator() self.menuFile.addAction(self.actQuit) self.menuHelp.addAction(self.actShowHelp) + self.menuHelp.addSeparator() + self.menuHelp.addAction(self.actSupport) + self.menuHelp.addAction(self.actLocateLog) + self.menuHelp.addSeparator() self.menuHelp.addAction(self.actAbout) self.menuTools.addAction(self.actSpellcheck) self.menuTools.addAction(self.actToolFrequency) @@ -1317,6 +1286,7 @@ class Ui_MainWindow(object): self.menuEdit.addAction(self.actCopy) self.menuEdit.addAction(self.actPaste) self.menuEdit.addAction(self.actDelete) + self.menuEdit.addAction(self.actSearch) self.menuEdit.addAction(self.actRename) self.menuEdit.addSeparator() self.menuEdit.addAction(self.mnuFormat.menuAction()) @@ -1343,20 +1313,109 @@ class Ui_MainWindow(object): self.retranslateUi(MainWindow) self.stack.setCurrentIndex(1) - self.tabMain.setCurrentIndex(3) + self.tabMain.setCurrentIndex(2) self.tabSummary.setCurrentIndex(0) - self.tabPersos.setCurrentIndex(2) - self.tabPlot.setCurrentIndex(1) + self.tabPersos.setCurrentIndex(3) + self.tabPlot.setCurrentIndex(0) self.comboBox_2.setCurrentIndex(0) self.stkPlotSummary.setCurrentIndex(0) self.tabWorld.setCurrentIndex(0) - self.tabWidget.setCurrentIndex(2) - self.comboBox_2.currentIndexChanged['int'].connect(self.stkPlotSummary.setCurrentIndex) - self.btnPlanShowDetails.toggled['bool'].connect(self.frame.setVisible) - self.cmbSummary.currentIndexChanged['int'].connect(self.tabSummary.setCurrentIndex) - self.tabSummary.currentChanged['int'].connect(self.cmbSummary.setCurrentIndex) - self.btnShowSubPlotSummary.toggled['bool'].connect(self.grpSubPlotSummary.setVisible) + self.tabWidget.setCurrentIndex(0) + self.comboBox_2.currentIndexChanged['int'].connect(self.stkPlotSummary.setCurrentIndex) # type: ignore + self.btnPlanShowDetails.toggled['bool'].connect(self.frame.setVisible) # type: ignore + self.cmbSummary.currentIndexChanged['int'].connect(self.tabSummary.setCurrentIndex) # type: ignore + self.tabSummary.currentChanged['int'].connect(self.cmbSummary.setCurrentIndex) # type: ignore + self.btnShowSubPlotSummary.toggled['bool'].connect(self.grpSubPlotSummary.setVisible) # type: ignore QtCore.QMetaObject.connectSlotsByName(MainWindow) + MainWindow.setTabOrder(self.tabMain, self.txtGeneralTitle) + MainWindow.setTabOrder(self.txtGeneralTitle, self.txtGeneralSubtitle) + MainWindow.setTabOrder(self.txtGeneralSubtitle, self.txtGeneralSerie) + MainWindow.setTabOrder(self.txtGeneralSerie, self.txtGeneralVolume) + MainWindow.setTabOrder(self.txtGeneralVolume, self.txtGeneralGenre) + MainWindow.setTabOrder(self.txtGeneralGenre, self.txtGeneralLicense) + MainWindow.setTabOrder(self.txtGeneralLicense, self.txtGeneralAuthor) + MainWindow.setTabOrder(self.txtGeneralAuthor, self.txtGeneralEmail) + MainWindow.setTabOrder(self.txtGeneralEmail, self.cmbSummary) + MainWindow.setTabOrder(self.cmbSummary, self.txtSummarySentence) + MainWindow.setTabOrder(self.txtSummarySentence, self.txtSummarySentence_2) + MainWindow.setTabOrder(self.txtSummarySentence_2, self.txtSummaryPara) + MainWindow.setTabOrder(self.txtSummaryPara, self.txtSummaryPara_2) + MainWindow.setTabOrder(self.txtSummaryPara_2, self.txtSummaryPage) + MainWindow.setTabOrder(self.txtSummaryPage, self.txtSummaryPage_2) + MainWindow.setTabOrder(self.txtSummaryPage_2, self.txtSummaryFull) + MainWindow.setTabOrder(self.txtSummaryFull, self.txtSummarySituation) + MainWindow.setTabOrder(self.txtSummarySituation, self.lstCharacters) + MainWindow.setTabOrder(self.lstCharacters, self.btnAddPerso) + MainWindow.setTabOrder(self.btnAddPerso, self.btnRmPerso) + MainWindow.setTabOrder(self.btnRmPerso, self.txtPersosFilter) + MainWindow.setTabOrder(self.txtPersosFilter, self.tabPersos) + MainWindow.setTabOrder(self.tabPersos, self.scrollAreaPersoInfos) + MainWindow.setTabOrder(self.scrollAreaPersoInfos, self.txtPersoName) + MainWindow.setTabOrder(self.txtPersoName, self.btnPersoColor) + MainWindow.setTabOrder(self.btnPersoColor, self.txtPersoMotivation) + MainWindow.setTabOrder(self.txtPersoMotivation, self.txtPersoGoal) + MainWindow.setTabOrder(self.txtPersoGoal, self.txtPersoConflict) + MainWindow.setTabOrder(self.txtPersoConflict, self.txtPersoEpiphany) + MainWindow.setTabOrder(self.txtPersoEpiphany, self.txtPersoSummarySentence) + MainWindow.setTabOrder(self.txtPersoSummarySentence, self.txtPersoSummaryPara) + MainWindow.setTabOrder(self.txtPersoSummaryPara, self.txtPersoSummaryFull) + MainWindow.setTabOrder(self.txtPersoSummaryFull, self.txtPersoNotes) + MainWindow.setTabOrder(self.txtPersoNotes, self.tblPersoInfos) + MainWindow.setTabOrder(self.tblPersoInfos, self.btnPersoAddInfo) + MainWindow.setTabOrder(self.btnPersoAddInfo, self.btnPersoRmInfo) + MainWindow.setTabOrder(self.btnPersoRmInfo, self.lineEdit) + MainWindow.setTabOrder(self.lineEdit, self.lstPlots) + MainWindow.setTabOrder(self.lstPlots, self.btnAddPlot) + MainWindow.setTabOrder(self.btnAddPlot, self.btnRmPlot) + MainWindow.setTabOrder(self.btnRmPlot, self.txtPlotFilter) + MainWindow.setTabOrder(self.txtPlotFilter, self.tabPlot) + MainWindow.setTabOrder(self.tabPlot, self.txtPlotName) + MainWindow.setTabOrder(self.txtPlotName, self.lstPlotPerso) + MainWindow.setTabOrder(self.lstPlotPerso, self.btnAddPlotPerso) + MainWindow.setTabOrder(self.btnAddPlotPerso, self.btnRmPlotPerso) + MainWindow.setTabOrder(self.btnRmPlotPerso, self.txtPlotDescription) + MainWindow.setTabOrder(self.txtPlotDescription, self.txtPlotResult) + MainWindow.setTabOrder(self.txtPlotResult, self.lstSubPlots) + MainWindow.setTabOrder(self.lstSubPlots, self.txtSubPlotSummary) + MainWindow.setTabOrder(self.txtSubPlotSummary, self.btnAddSubPlot) + MainWindow.setTabOrder(self.btnAddSubPlot, self.btnRmSubPlot) + MainWindow.setTabOrder(self.btnRmSubPlot, self.btnShowSubPlotSummary) + MainWindow.setTabOrder(self.btnShowSubPlotSummary, self.comboBox_2) + MainWindow.setTabOrder(self.comboBox_2, self.txtPlotSummaryPara) + MainWindow.setTabOrder(self.txtPlotSummaryPara, self.txtPlotSummaryPage) + MainWindow.setTabOrder(self.txtPlotSummaryPage, self.txtPlotSummaryFull) + MainWindow.setTabOrder(self.txtPlotSummaryFull, self.treeWorld) + MainWindow.setTabOrder(self.treeWorld, self.btnAddWorld) + MainWindow.setTabOrder(self.btnAddWorld, self.btnRmWorld) + MainWindow.setTabOrder(self.btnRmWorld, self.txtWorldFilter) + MainWindow.setTabOrder(self.txtWorldFilter, self.btnWorldEmptyData) + MainWindow.setTabOrder(self.btnWorldEmptyData, self.tabWorld) + MainWindow.setTabOrder(self.tabWorld, self.txtWorldName) + MainWindow.setTabOrder(self.txtWorldName, self.txtWorldDescription) + MainWindow.setTabOrder(self.txtWorldDescription, self.txtWorldPassion) + MainWindow.setTabOrder(self.txtWorldPassion, self.txtWorldConflict) + MainWindow.setTabOrder(self.txtWorldConflict, self.lstOutlinePlots) + MainWindow.setTabOrder(self.lstOutlinePlots, self.treeOutlineOutline) + MainWindow.setTabOrder(self.treeOutlineOutline, self.btnOutlineAddFolder) + MainWindow.setTabOrder(self.btnOutlineAddFolder, self.btnOutlineAddText) + MainWindow.setTabOrder(self.btnOutlineAddText, self.btnOutlineRemoveItem) + MainWindow.setTabOrder(self.btnOutlineRemoveItem, self.btnPlanShowDetails) + MainWindow.setTabOrder(self.btnPlanShowDetails, self.treeRedacOutline) + MainWindow.setTabOrder(self.treeRedacOutline, self.btnRedacAddFolder) + MainWindow.setTabOrder(self.btnRedacAddFolder, self.btnRedacAddText) + MainWindow.setTabOrder(self.btnRedacAddText, self.btnRedacRemoveItem) + MainWindow.setTabOrder(self.btnRedacRemoveItem, self.tabWidget) + MainWindow.setTabOrder(self.tabWidget, self.tblDebugFlatData) + MainWindow.setTabOrder(self.tblDebugFlatData, self.tblDebugPersos) + MainWindow.setTabOrder(self.tblDebugPersos, self.tblDebugPersosInfos) + MainWindow.setTabOrder(self.tblDebugPersosInfos, self.tblDebugPlots) + MainWindow.setTabOrder(self.tblDebugPlots, self.tblDebugPlotsPersos) + MainWindow.setTabOrder(self.tblDebugPlotsPersos, self.tblDebugSubPlots) + MainWindow.setTabOrder(self.tblDebugSubPlots, self.treeDebugWorld) + MainWindow.setTabOrder(self.treeDebugWorld, self.treeDebugOutline) + MainWindow.setTabOrder(self.treeDebugOutline, self.lstDebugLabels) + MainWindow.setTabOrder(self.lstDebugLabels, self.lstDebugStatus) + MainWindow.setTabOrder(self.lstDebugStatus, self.lstTabs) def retranslateUi(self, MainWindow): _translate = QtCore.QCoreApplication.translate @@ -1372,6 +1431,7 @@ class Ui_MainWindow(object): self.label_19.setText(_translate("MainWindow", "Email")) self.tabMain.setTabText(self.tabMain.indexOf(self.lytTabOverview), _translate("MainWindow", "General")) self.label_9.setText(_translate("MainWindow", "Situation:")) + self.txtSummarySituation.setPlaceholderText(_translate("MainWindow", "What if...?")) self.label_29.setText(_translate("MainWindow", "Summary:")) self.cmbSummary.setItemText(0, _translate("MainWindow", "One sentence")) self.cmbSummary.setItemText(1, _translate("MainWindow", "One paragraph")) @@ -1384,29 +1444,22 @@ class Ui_MainWindow(object): self.label_17.setText(_translate("MainWindow", "Expand each sentence of your one paragraph summary to a paragraph")) self.label_23.setText(_translate("MainWindow", "One page summary")) self.label_20.setText(_translate("MainWindow", "Full summary")) - self.btnStepThree.setText(_translate("MainWindow", "Next")) - self.btnStepTwo.setText(_translate("MainWindow", "Next")) - self.btnStepFive.setText(_translate("MainWindow", "Next")) - self.btnStepSeven.setText(_translate("MainWindow", "Next")) - self.txtSummarySituation.setPlaceholderText(_translate("MainWindow", "What if...?")) self.tabMain.setTabText(self.tabMain.indexOf(self.lytTabSummary), _translate("MainWindow", "Summary")) self.groupBox.setTitle(_translate("MainWindow", "Names")) self.txtPersosFilter.setPlaceholderText(_translate("MainWindow", "Filter")) + self.label_3.setText(_translate("MainWindow", "Name")) + self.label_18.setText(_translate("MainWindow", "Importance")) + self.chkPersoPOV.setText(_translate("MainWindow", "Allow POV")) self.label_4.setText(_translate("MainWindow", "Motivation")) self.label_5.setText(_translate("MainWindow", "Goal")) self.label_6.setText(_translate("MainWindow", "Conflict")) self.label_7.setText(_translate("MainWindow", "Epiphany")) self.label_24.setText(_translate("MainWindow", "GenericListDialog ++ ++ ++ +0 +0 +451 +340 ++ +Title ++ +false ++ +- +
++ +- +
++ ++ +Text +- +
++ - +
++ ++ +Qt::Horizontal ++ +QDialogButtonBox::Ok ++ + ++ +buttonBox +accepted() +GenericListDialog +accept() ++ ++ +248 +254 ++ +157 +274 ++ +buttonBox +rejected() +GenericListDialog +reject() ++ ++ +316 +260 ++ +286 +274 +One sentence
")) self.label_8.setText(_translate("MainWindow", "
summaryOne paragraph
")) - self.btnStepFour.setText(_translate("MainWindow", "Next")) - self.label_18.setText(_translate("MainWindow", "Importance")) - self.label_3.setText(_translate("MainWindow", "Name")) self.tabPersos.setTabText(self.tabPersos.indexOf(self.info), _translate("MainWindow", "Basic info")) - self.btnStepSix.setText(_translate("MainWindow", "Next")) self.tabPersos.setTabText(self.tabPersos.indexOf(self.tab_11), _translate("MainWindow", "Summary")) self.tabPersos.setTabText(self.tabPersos.indexOf(self.tab_19), _translate("MainWindow", "Notes")) self.lineEdit.setPlaceholderText(_translate("MainWindow", "Filter")) - self.btnStepEight.setText(_translate("MainWindow", "Next")) self.tabPersos.setTabText(self.tabPersos.indexOf(self.tab_12), _translate("MainWindow", "Detailed info")) self.tabMain.setTabText(self.tabMain.indexOf(self.lytTabPersos), _translate("MainWindow", "Characters")) self.groupBox_2.setTitle(_translate("MainWindow", "Plots")) @@ -1547,6 +1600,15 @@ class Ui_MainWindow(object): self.actToolTargets.setToolTip(_translate("MainWindow", "Draft and session word count targets")) self.actToolTargets.setShortcut(_translate("MainWindow", "Ctrl+,")) + self.actSearch.setText(_translate("MainWindow", "Search")) + self.actSearch.setShortcut(_translate("MainWindow", "Ctrl+F")) + self.actSupport.setText(_translate("MainWindow", "&Technical Support")) + self.actSupport.setToolTip(_translate("MainWindow", "How to obtain technical support for Manuskript.")) + self.actSupport.setShortcut(_translate("MainWindow", "F1")) + self.actLocateLog.setText(_translate("MainWindow", "&Locate log file...")) + self.actLocateLog.setIconText(_translate("MainWindow", "Locate log file")) + self.actLocateLog.setToolTip(_translate("MainWindow", "Locate the diagnostic log file used for this session.")) + self.actLocateLog.setShortcut(_translate("MainWindow", "Shift+F1")) from manuskript.ui.cheatSheet import cheatSheet from manuskript.ui.editors.mainEditor import mainEditor from manuskript.ui.search import search diff --git a/manuskript/ui/mainWindow.ui b/manuskript/ui/mainWindow.ui index d0a07d6..330c8d1 100644 --- a/manuskript/ui/mainWindow.ui +++ b/manuskript/ui/mainWindow.ui @@ -42,6 +42,9 @@
summary+ Qt::Horizontal + QSizePolicy::Minimum +40 @@ -55,6 +58,9 @@+ Qt::Horizontal + QSizePolicy::Minimum +40 @@ -78,6 +84,9 @@+ Qt::Vertical + QSizePolicy::Minimum +20 @@ -91,6 +100,9 @@+ Qt::Vertical + QSizePolicy::Minimum +20 @@ -124,7 +136,7 @@QTabWidget::Rounded - 3 +2 true @@ -314,7 +326,6 @@- @@ -323,11 +334,21 @@75 true - +
+ +- +
++ ++ +What if...? +- -
- @@ -607,78 +628,6 @@75 true - -
-- -- -
-- -- -Qt::Horizontal -- -- -40 -20 -- -
-- -- -Next -- -- -. .- -
-- -- -Next -- -- -. .- -
-- -- -Next -- -- -. .- -
-- -- -Next -- -- -. .- -
@@ -774,7 +723,7 @@- -- -
-- -- -What if...? -@@ -966,34 +904,6 @@ - 2 +3 @@ -815,122 +764,14 @@ 0 0 -252 -664 +453 +699 + - QFormLayout::AllNonFixedFieldsGrow - -
-- -- -Motivation -- -
-- - -
-- -- -Goal -- -
-- - -
-- -- -Conflict -- -
-- - -
-- -- -Epiphany -- -
-- - -
-- -- -<html><head/><body><p align="right">One sentence<br/>summary</p></body></html> -- -
-- - -
-- -- -<html><head/><body><p align="right">One paragraph<br/>summary</p></body></html> -- -
-- - -
-- -- -
-- -- -Qt::Horizontal -- -- -40 -20 -- -
-- -- -Next -- -- -. .- -true -- -
-- -- -Importance -- -
- -- -- -0 -0 -@@ -952,6 +793,103 @@ - +
++ ++ +Importance +- +
++ +- +
++ ++ ++ +0 +0 +- +
++ ++ +Allow POV ++ +false ++ +false ++ +false +- +
++ ++ +Motivation +- +
++ - +
++ ++ +Goal +- +
++ - +
++ ++ +Conflict +- +
++ - +
++ ++ +Epiphany +- +
++ - +
++ ++ +<html><head/><body><p align="right">One sentence<br/>summary</p></body></html> +- +
++ - +
++ ++ +<html><head/><body><p align="right">One paragraph<br/>summary</p></body></html> +- +
+ - -
- -
- -- -
-- -- -Qt::Horizontal -- -- -40 -20 -- -
-- -- -Next -- -- -. .@@ -1068,17 +978,6 @@ - - -
@@ -1173,7 +1072,7 @@- -- -Next -- -- -. .@@ -1991,7 +1890,7 @@ - 1 +0 true @@ -1547,7 +1446,7 @@false - 0 +35 QTabWidget::West - 2 +0 @@ -2095,7 +1994,7 @@ 0 0 1112 -25 +23 @@ -2127,6 +2026,10 @@ &Help + + + + @@ -2184,6 +2087,7 @@ @@ -2946,7 +3104,7 @@+ @@ -2250,7 +2154,6 @@ - @@ -2284,7 +2187,6 @@50 false @@ -2911,6 +2848,98 @@ - @@ -2828,6 +2730,41 @@50 false Ctrl+, ++ ++ ++ +. .+ +Search ++ +Ctrl+F ++ ++ +&Technical Support ++ +How to obtain technical support for Manuskript. ++ +F1 ++ + +&Locate log file... ++ +Locate log file ++ +Locate the diagnostic log file used for this session. ++ Shift+F1 1 ++ tabMain +txtGeneralTitle +txtGeneralSubtitle +txtGeneralSerie +txtGeneralVolume +txtGeneralGenre +txtGeneralLicense +txtGeneralAuthor +txtGeneralEmail +cmbSummary +txtSummarySentence +txtSummarySentence_2 +txtSummaryPara +txtSummaryPara_2 +txtSummaryPage +txtSummaryPage_2 +txtSummaryFull +txtSummarySituation +lstCharacters +btnAddPerso +btnRmPerso +txtPersosFilter +tabPersos +scrollAreaPersoInfos +txtPersoName +btnPersoColor +txtPersoMotivation +txtPersoGoal +txtPersoConflict +txtPersoEpiphany +txtPersoSummarySentence +txtPersoSummaryPara +txtPersoSummaryFull +txtPersoNotes +tblPersoInfos +btnPersoAddInfo +btnPersoRmInfo +lineEdit +lstPlots +btnAddPlot +btnRmPlot +txtPlotFilter +tabPlot +txtPlotName +lstPlotPerso +btnAddPlotPerso +btnRmPlotPerso +txtPlotDescription +txtPlotResult +lstSubPlots +txtSubPlotSummary +btnAddSubPlot +btnRmSubPlot +btnShowSubPlotSummary +comboBox_2 +txtPlotSummaryPara +txtPlotSummaryPage +txtPlotSummaryFull +treeWorld +btnAddWorld +btnRmWorld +txtWorldFilter +btnWorldEmptyData +tabWorld +txtWorldName +txtWorldDescription +txtWorldPassion +txtWorldConflict +lstOutlinePlots +treeOutlineOutline +btnOutlineAddFolder +btnOutlineAddText +btnOutlineRemoveItem +btnPlanShowDetails +treeRedacOutline +btnRedacAddFolder +btnRedacAddText +btnRedacRemoveItem +tabWidget +tblDebugFlatData +tblDebugPersos +tblDebugPersosInfos +tblDebugPlots +tblDebugPlotsPersos +tblDebugSubPlots +treeDebugWorld +treeDebugOutline +lstDebugLabels +lstDebugStatus +lstTabs +diff --git a/manuskript/ui/search.py b/manuskript/ui/search.py index 06441ae..b9b9977 100644 --- a/manuskript/ui/search.py +++ b/manuskript/ui/search.py @@ -1,147 +1,186 @@ #!/usr/bin/env python # --!-- coding: utf8 --!-- -from PyQt5.QtCore import Qt, QRect -from PyQt5.QtGui import QPalette, QFontMetrics -from PyQt5.QtWidgets import QWidget, QMenu, QAction, qApp, QListWidgetItem, QStyledItemDelegate, QStyle +import re + +from PyQt5.QtCore import Qt, QRect, QEvent, QCoreApplication +from PyQt5.QtGui import QPalette, QFontMetrics, QKeySequence +from PyQt5.QtWidgets import QWidget, qApp, QListWidgetItem, QStyledItemDelegate, QStyle, QLabel, QToolTip, QShortcut + -from manuskript.enums import Outline from manuskript.functions import mainWindow from manuskript.ui import style from manuskript.ui.search_ui import Ui_search -from manuskript.models import references as Ref +from manuskript.enums import Model + +from manuskript.models.flatDataModelWrapper import flatDataModelWrapper +from manuskript.ui.searchMenu import searchMenu +from manuskript.ui.highlighters.searchResultHighlighters.searchResultHighlighter import searchResultHighlighter +import logging +LOGGER = logging.getLogger(__name__) class search(QWidget, Ui_search): def __init__(self, parent=None): + _translate = QCoreApplication.translate + QWidget.__init__(self, parent) self.setupUi(self) - self.options = { - "All": True, - "Title": True, - "Text": True, - "Summary": False, - "Notes": False, - "POV": False, - "Status": False, - "Label": False, - "CS": True - } + self.searchTextInput.returnPressed.connect(self.search) + self.searchTextInput.textChanged.connect(self.updateSearchFeedback) - self.text.returnPressed.connect(self.search) - self.generateOptionMenu() + self.searchMenu = searchMenu() + self.btnOptions.setMenu(self.searchMenu) + self.searchMenu.triggered.connect(self.onSearchMenuChange) self.delegate = listResultDelegate(self) self.result.setItemDelegate(self.delegate) + self.result.setMouseTracking(True) self.result.itemClicked.connect(self.openItem) self.result.setStyleSheet(style.searchResultSS()) - self.text.setStyleSheet(style.lineEditSS()) + self.searchTextInput.setStyleSheet(style.lineEditSS()) - def generateOptionMenu(self): - self.menu = QMenu(self) - a = QAction(self.tr("Search in:"), self.menu) - a.setEnabled(False) - self.menu.addAction(a) - for i, d in [ - (self.tr("All"), "All"), - (self.tr("Title"), "Title"), - (self.tr("Text"), "Text"), - (self.tr("Summary"), "Summary"), - (self.tr("Notes"), "Notes"), - (self.tr("POV"), "POV"), - (self.tr("Status"), "Status"), - (self.tr("Label"), "Label"), - ]: - a = QAction(i, self.menu) - a.setCheckable(True) - a.setChecked(self.options[d]) - a.setData(d) - a.triggered.connect(self.updateOptions) - self.menu.addAction(a) - self.menu.addSeparator() + self.searchResultHighlighter = searchResultHighlighter() - a = QAction(self.tr("Options:"), self.menu) - a.setEnabled(False) - self.menu.addAction(a) - for i, d in [ - (self.tr("Case sensitive"), "CS"), - ]: - a = QAction(i, self.menu) - a.setCheckable(True) - a.setChecked(self.options[d]) - a.setData(d) - a.triggered.connect(self.updateOptions) - self.menu.addAction(a) - self.menu.addSeparator() + self.noResultsLabel = QLabel(_translate("Search", "No results found"), self.result) + self.noResultsLabel.setVisible(False) + self.noResultsLabel.setStyleSheet("QLabel {color: gray;}") - self.btnOptions.setMenu(self.menu) + # Add shortcuts for navigating through search results + QShortcut(QKeySequence(_translate("MainWindow", "F3")), self.searchTextInput, self.nextSearchResult) + QShortcut(QKeySequence(_translate("MainWindow", "Shift+F3")), self.searchTextInput, self.previousSearchResult) - def updateOptions(self): - a = self.sender() - self.options[a.data()] = a.isChecked() + # These texts are already included in translation files but including ":" at the end. We force here the + # translation for them without ":" + _translate("MainWindow", "Situation") + _translate("MainWindow", "Status") + + def nextSearchResult(self): + if self.result.currentRow() < self.result.count() - 1: + self.result.setCurrentRow(self.result.currentRow() + 1) + else: + self.result.setCurrentRow(0) + + if 0 < self.result.currentRow() < self.result.count(): + self.openItem(self.result.currentItem()) + + def previousSearchResult(self): + if self.result.currentRow() > 0: + self.result.setCurrentRow(self.result.currentRow() - 1) + else: + self.result.setCurrentRow(self.result.count() - 1) + + if 0 < self.result.currentRow() < self.result.count(): + self.openItem(self.result.currentItem()) + + def onSearchMenuChange(self): + search_string = self.searchTextInput.text() + self.updateSearchFeedback(search_string) + + def updateSearchFeedback(self, search_string): + palette = QPalette() + try: + self.compileRegex(search_string) + except Exception as e: + # From https://stackoverflow.com/questions/27432456/python-qlineedit-text-color + palette.setColor(QPalette.Text, Qt.red) + + self.searchTextInput.setPalette(palette) + + def prepareRegex(self, searchText): + rtn = None + try: + rtn = self.compileRegex(searchText) + except re.error as e: + LOGGER.info("Problem preparing regular expression: " + e.msg) + rtn = None + except Exception as e: + LOGGER.info("Problem preparing regular expression") + rtn = None + return rtn + + def compileRegex(self, searchText): + # Intentionally throws exceptions for use elsewhere + flags = re.UNICODE + + if self.searchMenu.caseSensitive() is False: + flags |= re.IGNORECASE + + if self.searchMenu.regex() is False: + searchText = re.escape(searchText) + + if self.searchMenu.matchWords() is True: + # Source: https://stackoverflow.com/a/15863102 + searchText = r'\b' + searchText + r'\b' + + return re.compile(searchText, flags) def search(self): - text = self.text.text() - - # Choosing the right columns - lstColumns = [ - ("Title", Outline.title), - ("Text", Outline.text), - ("Summary", Outline.summarySentence), - ("Summary", Outline.summaryFull), - ("Notes", Outline.notes), - ("POV", Outline.POV), - ("Status", Outline.status), - ("Label", Outline.label), - ] - columns = [c[1] for c in lstColumns if self.options[c[0]] or self.options["All"]] - - # Setting override cursor - qApp.setOverrideCursor(Qt.WaitCursor) - - # Searching - model = mainWindow().mdlOutline - results = model.findItemsContaining(text, columns, self.options["CS"]) - - # Showing results self.result.clear() - for r in results: - index = model.getIndexByID(r) - if not index.isValid(): - continue - item = index.internalPointer() - i = QListWidgetItem(item.title(), self.result) - i.setData(Qt.UserRole, r) - i.setData(Qt.UserRole + 1, item.path()) - self.result.addItem(i) + self.result.setCurrentRow(0) - # Removing override cursor - qApp.restoreOverrideCursor() + searchText = self.searchTextInput.text() + if len(searchText) > 0: + results = list() + searchRegex = self.prepareRegex(searchText) + if searchRegex is not None: + # Set override cursor + qApp.setOverrideCursor(Qt.WaitCursor) + + for model, modelName in [ + (mainWindow().mdlOutline, Model.Outline), + (mainWindow().mdlCharacter, Model.Character), + (flatDataModelWrapper(mainWindow().mdlFlatData), Model.FlatData), + (mainWindow().mdlWorld, Model.World), + (mainWindow().mdlPlots, Model.Plot) + ]: + filteredColumns = self.searchMenu.columns(modelName) + + # Searching + if len(filteredColumns): + results += model.searchOccurrences(searchRegex, filteredColumns) + + # Showing results + self.generateResultsLists(results) + + # Remove override cursor + qApp.restoreOverrideCursor() + else: + # No results to generate if there is a problem with the regex + self.generateResultsLists(list()) + + def generateResultsLists(self, results): + self.noResultsLabel.setVisible(len(results) == 0) + for result in results: + item = QListWidgetItem(result.title(), self.result) + item.setData(Qt.UserRole, result) + item.setData(Qt.UserRole + 1, ' > '.join(result.path())) + item.setData(Qt.UserRole + 2, result.context()) + self.result.addItem(item) def openItem(self, item): - r = Ref.textReference(item.data(Qt.UserRole)) - Ref.open(r) - # mw = mainWindow() - # index = mw.mdlOutline.getIndexByID(item.data(Qt.UserRole)) - # mw.mainEditor.setCurrentModelIndex(index, newTab=True) + self.searchResultHighlighter.highlightSearchResult(item.data(Qt.UserRole)) + + def leaveEvent(self, event): + self.delegate.mouseLeave() class listResultDelegate(QStyledItemDelegate): def __init__(self, parent=None): QStyledItemDelegate.__init__(self, parent) + self._tooltipRowIndex = -1 def paint(self, painter, option, index): extra = index.data(Qt.UserRole + 1) + if not extra: return QStyledItemDelegate.paint(self, painter, option, index) - else: if option.state & QStyle.State_Selected: painter.fillRect(option.rect, option.palette.color(QPalette.Highlight)) title = index.data() - extra = " - {}".format(extra) painter.drawText(option.rect.adjusted(2, 1, 0, 0), Qt.AlignLeft, title) fm = QFontMetrics(option.font) @@ -153,5 +192,18 @@ class listResultDelegate(QStyledItemDelegate): painter.setPen(Qt.white) else: painter.setPen(Qt.gray) - painter.drawText(r.adjusted(2, 1, 0, 0), Qt.AlignLeft, extra) + painter.drawText(r.adjusted(2, 1, 0, 0), Qt.AlignLeft, " - {}".format(extra)) painter.restore() + + def editorEvent(self, event, model, option, index): + if event.type() == QEvent.MouseMove and self._tooltipRowIndex != index.row(): + self._tooltipRowIndex = index.row() + context = index.data(Qt.UserRole + 2) + extra = index.data(Qt.UserRole + 1) + QToolTip.showText(event.globalPos(), + " #" + str(index.row()) + " - " + extra + "
" + context + "
") + return True + return False + + def mouseLeave(self): + self._tooltipRowIndex = -1 diff --git a/manuskript/ui/searchMenu.py b/manuskript/ui/searchMenu.py new file mode 100644 index 0000000..59468f8 --- /dev/null +++ b/manuskript/ui/searchMenu.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python +# --!-- coding: utf8 --!-- +from PyQt5.QtWidgets import QMenu, QAction +from PyQt5.QtCore import QCoreApplication +from PyQt5 import QtCore + +from manuskript.searchLabels import OutlineSearchLabels, CharacterSearchLabels, FlatDataSearchLabels, WorldSearchLabels, PlotSearchLabels +from manuskript.models.searchFilter import searchFilter +from manuskript.enums import Model + + +def filterKey(modelPreffix, column): + return modelPreffix + str(column) + + +class searchMenu(QMenu): + def __init__(self, parent=None): + QMenu.__init__(self, parent) + + _translate = QCoreApplication.translate + # Model keys must match the ones used in search widget class + self.filters = { + Model.Outline: searchFilter(_translate("MainWindow", "Outline"), True, list(OutlineSearchLabels.keys())), + Model.Character: searchFilter(_translate("MainWindow", "Characters"), True, list(CharacterSearchLabels.keys())), + Model.FlatData: searchFilter(_translate("MainWindow", "FlatData"), True, list(FlatDataSearchLabels.keys())), + Model.World: searchFilter(_translate("MainWindow", "World"), True, list(WorldSearchLabels.keys())), + Model.Plot: searchFilter(_translate("MainWindow", "Plot"), True, list(PlotSearchLabels.keys())) + } + + self.options = { + "CS": [self.tr("Case sensitive"), True], + "MatchWords": [self.tr("Match words"), False], + "Regex": [self.tr("Regex"), False] + } + + self._generateOptions() + + def _generateOptions(self): + a = QAction(self.tr("Search in:"), self) + a.setEnabled(False) + self.addAction(a) + for filterKey in self.filters: + a = QAction(self.tr(self.filters[filterKey].label()), self) + a.setCheckable(True) + a.setChecked(self.filters[filterKey].enabled()) + a.setData(filterKey) + a.triggered.connect(self._updateFilters) + self.addAction(a) + self.addSeparator() + + a = QAction(self.tr("Options:"), self) + a.setEnabled(False) + self.addAction(a) + for optionKey in self.options: + a = QAction(self.options[optionKey][0], self) + a.setCheckable(True) + a.setChecked(self.options[optionKey][1]) + a.setData(optionKey) + a.triggered.connect(self._updateOptions) + self.addAction(a) + self.addSeparator() + + def _updateFilters(self): + a = self.sender() + self.filters[a.data()].setEnabled(a.isChecked()) + + def _updateOptions(self): + a = self.sender() + self.options[a.data()][1] = a.isChecked() + + def columns(self, modelName): + if self.filters[modelName].enabled(): + return self.filters[modelName].modelColumns() + else: + return [] + + def caseSensitive(self): + return self.options["CS"][1] + + def matchWords(self): + return self.options["MatchWords"][1] + + def regex(self): + return self.options["Regex"][1] + + def mouseReleaseEvent(self, event): + # Workaround for enabling / disabling actions without closing the menu. + # Source: https://stackoverflow.com/a/14967212 + action = self.activeAction() + if action: + action.setEnabled(False) + QMenu.mouseReleaseEvent(self, event) + action.setEnabled(True) + action.trigger() + else: + QMenu.mouseReleaseEvent(self, event) + + def keyPressEvent(self, event): + # Workaround for enabling / disabling actions without closing the menu. + # Source: https://stackoverflow.com/a/14967212 + action = self.activeAction() + if action and event.key() == QtCore.Qt.Key_Return: + action.setEnabled(False) + QMenu.keyPressEvent(self, event) + action.setEnabled(True) + action.trigger() + else: + QMenu.keyPressEvent(self, event) diff --git a/manuskript/ui/search_ui.py b/manuskript/ui/search_ui.py index d9f5c31..5052977 100644 --- a/manuskript/ui/search_ui.py +++ b/manuskript/ui/search_ui.py @@ -19,12 +19,12 @@ class Ui_search(object): self.horizontalLayout = QtWidgets.QHBoxLayout() self.horizontalLayout.setSpacing(0) self.horizontalLayout.setObjectName("horizontalLayout") - self.text = QtWidgets.QLineEdit(search) - self.text.setInputMask("") - self.text.setFrame(False) - self.text.setClearButtonEnabled(True) - self.text.setObjectName("text") - self.horizontalLayout.addWidget(self.text) + self.searchTextInput = QtWidgets.QLineEdit(search) + self.searchTextInput.setInputMask("") + self.searchTextInput.setFrame(False) + self.searchTextInput.setClearButtonEnabled(True) + self.searchTextInput.setObjectName("searchTextInput") + self.horizontalLayout.addWidget(self.searchTextInput) self.btnOptions = QtWidgets.QPushButton(search) self.btnOptions.setText("") icon = QtGui.QIcon.fromTheme("edit-find") @@ -45,5 +45,5 @@ class Ui_search(object): def retranslateUi(self, search): _translate = QtCore.QCoreApplication.translate search.setWindowTitle(_translate("search", "Form")) - self.text.setPlaceholderText(_translate("search", "Search for...")) + self.searchTextInput.setPlaceholderText(_translate("search", "Search for...")) diff --git a/manuskript/ui/search_ui.ui b/manuskript/ui/search_ui.ui index 1b63fdc..89eb0a0 100644 --- a/manuskript/ui/search_ui.ui +++ b/manuskript/ui/search_ui.ui @@ -35,7 +35,7 @@0 - -
+ diff --git a/manuskript/ui/settings_ui.py b/manuskript/ui/settings_ui.py index d958116..90ec80d 100644 --- a/manuskript/ui/settings_ui.py +++ b/manuskript/ui/settings_ui.py @@ -2,16 +2,19 @@ # Form implementation generated from reading ui file 'manuskript/ui/settings_ui.ui' # -# Created by: PyQt5 UI code generator 5.9 +# Created by: PyQt5 UI code generator 5.15.7 # -# WARNING! All changes made in this file will be lost! +# WARNING: Any manual changes made to this file will be lost when pyuic5 is +# run again. Do not edit this file unless you know what you are doing. + from PyQt5 import QtCore, QtGui, QtWidgets + class Ui_Settings(object): def setupUi(self, Settings): Settings.setObjectName("Settings") - Settings.resize(658, 598) + Settings.resize(681, 598) self.horizontalLayout_8 = QtWidgets.QHBoxLayout(Settings) self.horizontalLayout_8.setObjectName("horizontalLayout_8") self.lstMenu = QtWidgets.QListWidget(Settings) @@ -53,50 +56,9 @@ class Ui_Settings(object): self.groupBox_2.setFont(font) self.groupBox_2.setObjectName("groupBox_2") self.formLayout_13 = QtWidgets.QFormLayout(self.groupBox_2) - self.formLayout_13.setFieldGrowthPolicy(QtWidgets.QFormLayout.FieldsStayAtSizeHint) self.formLayout_13.setObjectName("formLayout_13") - self.label_56 = QtWidgets.QLabel(self.groupBox_2) - font = QtGui.QFont() - font.setBold(False) - font.setWeight(50) - self.label_56.setFont(font) - self.label_56.setObjectName("label_56") - self.formLayout_13.setWidget(3, QtWidgets.QFormLayout.LabelRole, self.label_56) - self.cmbStyle = QtWidgets.QComboBox(self.groupBox_2) - font = QtGui.QFont() - font.setBold(False) - font.setWeight(50) - self.cmbStyle.setFont(font) - self.cmbStyle.setObjectName("cmbStyle") - self.formLayout_13.setWidget(3, QtWidgets.QFormLayout.FieldRole, self.cmbStyle) - self.label_57 = QtWidgets.QLabel(self.groupBox_2) - font = QtGui.QFont() - font.setBold(False) - font.setWeight(50) - self.label_57.setFont(font) - self.label_57.setObjectName("label_57") - self.formLayout_13.setWidget(6, QtWidgets.QFormLayout.LabelRole, self.label_57) - self.cmbTranslation = QtWidgets.QComboBox(self.groupBox_2) - font = QtGui.QFont() - font.setBold(False) - font.setWeight(50) - self.cmbTranslation.setFont(font) - self.cmbTranslation.setObjectName("cmbTranslation") - self.formLayout_13.setWidget(6, QtWidgets.QFormLayout.FieldRole, self.cmbTranslation) - self.label_58 = QtWidgets.QLabel(self.groupBox_2) - font = QtGui.QFont() - font.setBold(False) - font.setWeight(50) - self.label_58.setFont(font) - self.label_58.setObjectName("label_58") - self.formLayout_13.setWidget(7, QtWidgets.QFormLayout.LabelRole, self.label_58) - self.spnGeneralFontSize = QtWidgets.QSpinBox(self.groupBox_2) - font = QtGui.QFont() - font.setBold(False) - font.setWeight(50) - self.spnGeneralFontSize.setFont(font) - self.spnGeneralFontSize.setObjectName("spnGeneralFontSize") - self.formLayout_13.setWidget(7, QtWidgets.QFormLayout.FieldRole, self.spnGeneralFontSize) + self.gridLayout_4 = QtWidgets.QGridLayout() + self.gridLayout_4.setObjectName("gridLayout_4") self.label_2 = QtWidgets.QLabel(self.groupBox_2) font = QtGui.QFont() font.setBold(False) @@ -104,7 +66,70 @@ class Ui_Settings(object): self.label_2.setFont(font) self.label_2.setWordWrap(True) self.label_2.setObjectName("label_2") - self.formLayout_13.setWidget(2, QtWidgets.QFormLayout.SpanningRole, self.label_2) + self.gridLayout_4.addWidget(self.label_2, 0, 0, 1, 1) + self.horizontalLayout_12 = QtWidgets.QHBoxLayout() + self.horizontalLayout_12.setSizeConstraint(QtWidgets.QLayout.SetDefaultConstraint) + self.horizontalLayout_12.setObjectName("horizontalLayout_12") + self.formLayout_14 = QtWidgets.QFormLayout() + self.formLayout_14.setFieldGrowthPolicy(QtWidgets.QFormLayout.AllNonFixedFieldsGrow) + self.formLayout_14.setObjectName("formLayout_14") + self.label_56 = QtWidgets.QLabel(self.groupBox_2) + font = QtGui.QFont() + font.setBold(False) + font.setWeight(50) + self.label_56.setFont(font) + self.label_56.setObjectName("label_56") + self.formLayout_14.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.label_56) + self.cmbStyle = QtWidgets.QComboBox(self.groupBox_2) + font = QtGui.QFont() + font.setBold(False) + font.setWeight(50) + self.cmbStyle.setFont(font) + self.cmbStyle.setObjectName("cmbStyle") + self.formLayout_14.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.cmbStyle) + self.label_57 = QtWidgets.QLabel(self.groupBox_2) + font = QtGui.QFont() + font.setBold(False) + font.setWeight(50) + self.label_57.setFont(font) + self.label_57.setObjectName("label_57") + self.formLayout_14.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.label_57) + self.cmbTranslation = QtWidgets.QComboBox(self.groupBox_2) + font = QtGui.QFont() + font.setBold(False) + font.setWeight(50) + self.cmbTranslation.setFont(font) + self.cmbTranslation.setObjectName("cmbTranslation") + self.formLayout_14.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.cmbTranslation) + self.label_58 = QtWidgets.QLabel(self.groupBox_2) + font = QtGui.QFont() + font.setBold(False) + font.setWeight(50) + self.label_58.setFont(font) + self.label_58.setObjectName("label_58") + self.formLayout_14.setWidget(2, QtWidgets.QFormLayout.LabelRole, self.label_58) + self.spnGeneralFontSize = QtWidgets.QSpinBox(self.groupBox_2) + font = QtGui.QFont() + font.setBold(False) + font.setWeight(50) + self.spnGeneralFontSize.setFont(font) + self.spnGeneralFontSize.setObjectName("spnGeneralFontSize") + self.formLayout_14.setWidget(2, QtWidgets.QFormLayout.FieldRole, self.spnGeneralFontSize) + self.horizontalLayout_12.addLayout(self.formLayout_14) + self.formLayout_15 = QtWidgets.QFormLayout() + self.formLayout_15.setObjectName("formLayout_15") + self.chkProgressChars = QtWidgets.QCheckBox(self.groupBox_2) + font = QtGui.QFont() + font.setBold(False) + font.setWeight(50) + self.chkProgressChars.setFont(font) + self.chkProgressChars.setObjectName("chkProgressChars") + self.formLayout_15.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.chkProgressChars) + spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + self.formLayout_15.setItem(0, QtWidgets.QFormLayout.LabelRole, spacerItem) + self.horizontalLayout_12.addLayout(self.formLayout_15) + self.gridLayout_4.addLayout(self.horizontalLayout_12, 1, 0, 1, 1) + self.formLayout_13.setLayout(0, QtWidgets.QFormLayout.SpanningRole, self.gridLayout_4) self.verticalLayout_7.addWidget(self.groupBox_2) self.groupBox_10 = QtWidgets.QGroupBox(self.stackedWidgetPage1) font = QtGui.QFont() @@ -164,8 +189,8 @@ class Ui_Settings(object): self.label.setFont(font) self.label.setObjectName("label") self.horizontalLayout_5.addWidget(self.label) - spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.horizontalLayout_5.addItem(spacerItem) + spacerItem1 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + self.horizontalLayout_5.addItem(spacerItem1) self.verticalLayout_6.addLayout(self.horizontalLayout_5) self.horizontalLayout_7 = QtWidgets.QHBoxLayout() self.horizontalLayout_7.setObjectName("horizontalLayout_7") @@ -200,8 +225,8 @@ class Ui_Settings(object): self.label_14.setFont(font) self.label_14.setObjectName("label_14") self.horizontalLayout_7.addWidget(self.label_14) - spacerItem1 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.horizontalLayout_7.addItem(spacerItem1) + spacerItem2 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + self.horizontalLayout_7.addItem(spacerItem2) self.verticalLayout_6.addLayout(self.horizontalLayout_7) self.chkSaveOnQuit = QtWidgets.QCheckBox(self.groupBox) font = QtGui.QFont() @@ -221,8 +246,8 @@ class Ui_Settings(object): self.chkSaveToZip.setObjectName("chkSaveToZip") self.verticalLayout_6.addWidget(self.chkSaveToZip) self.verticalLayout_7.addWidget(self.groupBox) - spacerItem2 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) - self.verticalLayout_7.addItem(spacerItem2) + spacerItem3 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + self.verticalLayout_7.addItem(spacerItem3) self.stack.addWidget(self.stackedWidgetPage1) self.page_3 = QtWidgets.QWidget() self.page_3.setObjectName("page_3") @@ -288,6 +313,7 @@ class Ui_Settings(object): font.setWeight(50) self.spnRevisions10Mn.setFont(font) self.spnRevisions10Mn.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) + self.spnRevisions10Mn.setMinimum(1) self.spnRevisions10Mn.setMaximum(999) self.spnRevisions10Mn.setProperty("value", 1) self.spnRevisions10Mn.setObjectName("spnRevisions10Mn") @@ -324,6 +350,7 @@ class Ui_Settings(object): font.setWeight(50) self.spnRevisionsDay.setFont(font) self.spnRevisionsDay.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) + self.spnRevisionsDay.setMinimum(1) self.spnRevisionsDay.setMaximum(999) self.spnRevisionsDay.setProperty("value", 1) self.spnRevisionsDay.setObjectName("spnRevisionsDay") @@ -339,6 +366,7 @@ class Ui_Settings(object): font.setWeight(50) self.spnRevisionsHour.setFont(font) self.spnRevisionsHour.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) + self.spnRevisionsHour.setMinimum(1) self.spnRevisionsHour.setMaximum(999) self.spnRevisionsHour.setProperty("value", 1) self.spnRevisionsHour.setObjectName("spnRevisionsHour") @@ -354,6 +382,7 @@ class Ui_Settings(object): font.setWeight(50) self.spnRevisionsMonth.setFont(font) self.spnRevisionsMonth.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) + self.spnRevisionsMonth.setMinimum(1) self.spnRevisionsMonth.setMaximum(999) self.spnRevisionsMonth.setProperty("value", 1) self.spnRevisionsMonth.setObjectName("spnRevisionsMonth") @@ -369,6 +398,7 @@ class Ui_Settings(object): font.setWeight(50) self.spnRevisionsEternity.setFont(font) self.spnRevisionsEternity.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) + self.spnRevisionsEternity.setMinimum(1) self.spnRevisionsEternity.setMaximum(999) self.spnRevisionsEternity.setProperty("value", 1) self.spnRevisionsEternity.setObjectName("spnRevisionsEternity") @@ -381,8 +411,13 @@ class Ui_Settings(object): self.label_51.setObjectName("label_51") self.gridLayout_2.addWidget(self.label_51, 6, 1, 1, 1) self.verticalLayout.addWidget(self.chkRevisionRemove) - spacerItem3 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) - self.verticalLayout.addItem(spacerItem3) + spacerItem4 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + self.verticalLayout.addItem(spacerItem4) + self.label_revisionDeprecation = QtWidgets.QLabel(self.page_3) + self.label_revisionDeprecation.setWordWrap(True) + self.label_revisionDeprecation.setOpenExternalLinks(True) + self.label_revisionDeprecation.setObjectName("label_revisionDeprecation") + self.verticalLayout.addWidget(self.label_revisionDeprecation) self.stack.addWidget(self.page_3) self.stackedWidgetPage2 = QtWidgets.QWidget() self.stackedWidgetPage2.setObjectName("stackedWidgetPage2") @@ -512,6 +547,25 @@ class Ui_Settings(object): self.sldTreeIconSize.setObjectName("sldTreeIconSize") self.horizontalLayout_11.addWidget(self.sldTreeIconSize) self.verticalLayout_17.addWidget(self.groupBox_16) + self.horizontalGroupBox = QtWidgets.QGroupBox(self.tab) + font = QtGui.QFont() + font.setBold(True) + font.setWeight(75) + self.horizontalGroupBox.setFont(font) + self.horizontalGroupBox.setObjectName("horizontalGroupBox") + self.horizontalLayout_13 = QtWidgets.QHBoxLayout(self.horizontalGroupBox) + self.horizontalLayout_13.setContentsMargins(9, 9, 9, 9) + self.horizontalLayout_13.setObjectName("horizontalLayout_13") + self.chkCountSpaces = QtWidgets.QCheckBox(self.horizontalGroupBox) + font = QtGui.QFont() + font.setBold(False) + font.setWeight(50) + self.chkCountSpaces.setFont(font) + self.chkCountSpaces.setObjectName("chkCountSpaces") + self.horizontalLayout_13.addWidget(self.chkCountSpaces) + spacerItem5 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + self.horizontalLayout_13.addItem(spacerItem5) + self.verticalLayout_17.addWidget(self.horizontalGroupBox) self.horizontalLayout_9 = QtWidgets.QHBoxLayout() self.horizontalLayout_9.setObjectName("horizontalLayout_9") self.groupBox_8 = QtWidgets.QGroupBox(self.tab) @@ -536,6 +590,13 @@ class Ui_Settings(object): self.rdoTreeWC.setFont(font) self.rdoTreeWC.setObjectName("rdoTreeWC") self.verticalLayout_15.addWidget(self.rdoTreeWC) + self.rdoTreeCC = QtWidgets.QRadioButton(self.groupBox_8) + font = QtGui.QFont() + font.setBold(False) + font.setWeight(50) + self.rdoTreeCC.setFont(font) + self.rdoTreeCC.setObjectName("rdoTreeCC") + self.verticalLayout_15.addWidget(self.rdoTreeCC) self.rdoTreeProgress = QtWidgets.QRadioButton(self.groupBox_8) font = QtGui.QFont() font.setBold(False) @@ -574,6 +635,13 @@ class Ui_Settings(object): self.rdoTreeTextWC.setFont(font) self.rdoTreeTextWC.setObjectName("rdoTreeTextWC") self.verticalLayout_16.addWidget(self.rdoTreeTextWC) + self.rdoTreeTextCC = QtWidgets.QRadioButton(self.groupBox_9) + font = QtGui.QFont() + font.setBold(False) + font.setWeight(50) + self.rdoTreeTextCC.setFont(font) + self.rdoTreeTextCC.setObjectName("rdoTreeTextCC") + self.verticalLayout_16.addWidget(self.rdoTreeTextCC) self.rdoTreeTextProgress = QtWidgets.QRadioButton(self.groupBox_9) font = QtGui.QFont() font.setBold(False) @@ -595,12 +663,17 @@ class Ui_Settings(object): self.rdoTreeTextNothing.setFont(font) self.rdoTreeTextNothing.setObjectName("rdoTreeTextNothing") self.verticalLayout_16.addWidget(self.rdoTreeTextNothing) - spacerItem4 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) - self.verticalLayout_16.addItem(spacerItem4) + spacerItem6 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + self.verticalLayout_16.addItem(spacerItem6) + self.rdoTreeTextCC.raise_() + self.rdoTreeTextWC.raise_() + self.rdoTreeTextProgress.raise_() + self.rdoTreeTextSummary.raise_() + self.rdoTreeTextNothing.raise_() self.horizontalLayout_9.addWidget(self.groupBox_9) self.verticalLayout_17.addLayout(self.horizontalLayout_9) - spacerItem5 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) - self.verticalLayout_17.addItem(spacerItem5) + spacerItem7 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + self.verticalLayout_17.addItem(spacerItem7) icon = QtGui.QIcon.fromTheme("view-list-tree") self.tabViews.addTab(self.tab, icon, "") self.tab_2 = QtWidgets.QWidget() @@ -762,8 +835,8 @@ class Ui_Settings(object): self.chkOutlineTitle.setObjectName("chkOutlineTitle") self.gridLayout.addWidget(self.chkOutlineTitle, 3, 0, 1, 1) self.verticalLayout_11.addWidget(self.groupBox_6) - spacerItem6 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) - self.verticalLayout_11.addItem(spacerItem6) + spacerItem8 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + self.verticalLayout_11.addItem(spacerItem8) icon = QtGui.QIcon.fromTheme("view-outline") self.tabViews.addTab(self.tab_2, icon, "") self.tab_3 = QtWidgets.QWidget() @@ -809,8 +882,8 @@ class Ui_Settings(object): self.cmbCorkImage.setFont(font) self.cmbCorkImage.setObjectName("cmbCorkImage") self.verticalLayout_8.addWidget(self.cmbCorkImage) - spacerItem7 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) - self.verticalLayout_8.addItem(spacerItem7) + spacerItem9 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + self.verticalLayout_8.addItem(spacerItem9) self.gridLayout_3.addWidget(self.groupBox_7, 1, 1, 1, 1) self.groupBox_11 = QtWidgets.QGroupBox(self.tab_3) font = QtGui.QFont() @@ -1368,8 +1441,8 @@ class Ui_Settings(object): self.btnLabelColor.setIconSize(QtCore.QSize(64, 64)) self.btnLabelColor.setObjectName("btnLabelColor") self.verticalLayout_2.addWidget(self.btnLabelColor) - spacerItem8 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) - self.verticalLayout_2.addItem(spacerItem8) + spacerItem10 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + self.verticalLayout_2.addItem(spacerItem10) self.horizontalLayout_2.addLayout(self.verticalLayout_2) self.verticalLayout_3.addLayout(self.horizontalLayout_2) self.horizontalLayout = QtWidgets.QHBoxLayout() @@ -1386,8 +1459,8 @@ class Ui_Settings(object): self.btnLabelRemove.setIcon(icon) self.btnLabelRemove.setObjectName("btnLabelRemove") self.horizontalLayout.addWidget(self.btnLabelRemove) - spacerItem9 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.horizontalLayout.addItem(spacerItem9) + spacerItem11 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + self.horizontalLayout.addItem(spacerItem11) self.verticalLayout_3.addLayout(self.horizontalLayout) self.stack.addWidget(self.stackedWidgetPage3) self.stackedWidgetPage4 = QtWidgets.QWidget() @@ -1421,8 +1494,8 @@ class Ui_Settings(object): self.btnStatusRemove.setIcon(icon) self.btnStatusRemove.setObjectName("btnStatusRemove") self.horizontalLayout_3.addWidget(self.btnStatusRemove) - spacerItem10 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.horizontalLayout_3.addItem(spacerItem10) + spacerItem12 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + self.horizontalLayout_3.addItem(spacerItem12) self.verticalLayout_4.addLayout(self.horizontalLayout_3) self.stack.addWidget(self.stackedWidgetPage4) self.page = QtWidgets.QWidget() @@ -1470,8 +1543,8 @@ class Ui_Settings(object): self.btnThemeRemove.setIcon(icon) self.btnThemeRemove.setObjectName("btnThemeRemove") self.horizontalLayout_6.addWidget(self.btnThemeRemove) - spacerItem11 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.horizontalLayout_6.addItem(spacerItem11) + spacerItem13 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + self.horizontalLayout_6.addItem(spacerItem13) self.verticalLayout_12.addLayout(self.horizontalLayout_6) self.themeStack.addWidget(self.stackedWidgetPage1_3) self.stackedWidgetPage2_3 = QtWidgets.QWidget() @@ -1532,7 +1605,6 @@ class Ui_Settings(object): self.stackedWidgetPage1_2.setObjectName("stackedWidgetPage1_2") self.formLayout_4 = QtWidgets.QFormLayout(self.stackedWidgetPage1_2) self.formLayout_4.setFieldGrowthPolicy(QtWidgets.QFormLayout.AllNonFixedFieldsGrow) - self.formLayout_4.setContentsMargins(0, 0, 0, 0) self.formLayout_4.setObjectName("formLayout_4") self.label_17 = QtWidgets.QLabel(self.stackedWidgetPage1_2) self.label_17.setObjectName("label_17") @@ -1564,7 +1636,6 @@ class Ui_Settings(object): self.stackedWidgetPage2_2.setObjectName("stackedWidgetPage2_2") self.formLayout_5 = QtWidgets.QFormLayout(self.stackedWidgetPage2_2) self.formLayout_5.setFieldGrowthPolicy(QtWidgets.QFormLayout.AllNonFixedFieldsGrow) - self.formLayout_5.setContentsMargins(0, 0, 0, 0) self.formLayout_5.setObjectName("formLayout_5") self.label_20 = QtWidgets.QLabel(self.stackedWidgetPage2_2) self.label_20.setObjectName("label_20") @@ -1662,7 +1733,6 @@ class Ui_Settings(object): self.page_2.setObjectName("page_2") self.formLayout_7 = QtWidgets.QFormLayout(self.page_2) self.formLayout_7.setFieldGrowthPolicy(QtWidgets.QFormLayout.AllNonFixedFieldsGrow) - self.formLayout_7.setContentsMargins(0, 0, 0, 0) self.formLayout_7.setObjectName("formLayout_7") self.label_29 = QtWidgets.QLabel(self.page_2) self.label_29.setObjectName("label_29") @@ -1712,7 +1782,6 @@ class Ui_Settings(object): self.stackedWidgetPage3_2.setObjectName("stackedWidgetPage3_2") self.formLayout_6 = QtWidgets.QFormLayout(self.stackedWidgetPage3_2) self.formLayout_6.setFieldGrowthPolicy(QtWidgets.QFormLayout.AllNonFixedFieldsGrow) - self.formLayout_6.setContentsMargins(0, 0, 0, 0) self.formLayout_6.setObjectName("formLayout_6") self.label_26 = QtWidgets.QLabel(self.stackedWidgetPage3_2) self.label_26.setObjectName("label_26") @@ -1815,12 +1884,12 @@ class Ui_Settings(object): self.horizontalLayout_8.addWidget(self.stack) self.retranslateUi(Settings) - self.stack.setCurrentIndex(2) - self.tabViews.setCurrentIndex(3) - self.themeStack.setCurrentIndex(1) + self.stack.setCurrentIndex(0) + self.tabViews.setCurrentIndex(0) + self.themeStack.setCurrentIndex(0) self.themeEditStack.setCurrentIndex(3) - self.lstMenu.currentRowChanged['int'].connect(self.stack.setCurrentIndex) - self.chkRevisionsKeep.toggled['bool'].connect(self.chkRevisionRemove.setEnabled) + self.lstMenu.currentRowChanged['int'].connect(self.stack.setCurrentIndex) # type: ignore + self.chkRevisionsKeep.toggled['bool'].connect(self.chkRevisionRemove.setEnabled) # type: ignore QtCore.QMetaObject.connectSlotsByName(Settings) def retranslateUi(self, Settings): @@ -1843,10 +1912,12 @@ class Ui_Settings(object): self.lstMenu.setSortingEnabled(__sortingEnabled) self.lblTitleGeneral.setText(_translate("Settings", "General settings")) self.groupBox_2.setTitle(_translate("Settings", "Application settings")) + self.label_2.setText(_translate("Settings", "Restarting Manuskript ensures all settings take effect.")) self.label_56.setText(_translate("Settings", "Style:")) self.label_57.setText(_translate("Settings", "Language:")) self.label_58.setText(_translate("Settings", "Font size:")) - self.label_2.setText(_translate("Settings", "You might need to restart manuskript in order for those settings to take effect properly and entirely.")) + self.chkProgressChars.setText(_translate("Settings", "Show progress in chars next\n" +" to words")) self.groupBox_10.setTitle(_translate("Settings", "Loading")) self.chkAutoLoad.setText(_translate("Settings", "Automatically load last project on startup")) self.groupBox.setTitle(_translate("Settings", "Saving")) @@ -1854,8 +1925,8 @@ class Ui_Settings(object): self.label.setText(_translate("Settings", "minutes.")) self.chkAutoSaveNoChanges.setText(_translate("Settings", "If no changes during")) self.label_14.setText(_translate("Settings", "seconds.")) - self.chkSaveOnQuit.setText(_translate("Settings", "Save on quit")) - self.chkSaveToZip.setToolTip(_translate("Settings", " If you check this option, your project will be save as one single file. Easier to copy or backup, but does not allow collaborative editing, or versionning.
")) + self.chkSaveOnQuit.setText(_translate("Settings", "Save on project close")) + self.chkSaveToZip.setToolTip(_translate("Settings", "
If this is unchecked, your project will be save as a folder containing many small files.If you check this option, your project will be saved as one single file. Easier to copy or backup, but does not allow collaborative editing, or versioning.
")) self.chkSaveToZip.setText(_translate("Settings", "Save to one single file")) self.lblTitleGeneral_2.setText(_translate("Settings", "Revisions")) self.label_44.setText(_translate("Settings", "Revisions are a way to keep track of modifications. For each text item, it stores any changes you make to the main text, allowing you to see and restoring previous versions.")) @@ -1868,6 +1939,7 @@ class Ui_Settings(object): self.label_49.setText(_translate("Settings", "revisions per hour for the last day")) self.label_48.setText(_translate("Settings", "revisions per 10 minutes for the last hour")) self.label_51.setText(_translate("Settings", "revisions per week till the end of time")) + self.label_revisionDeprecation.setText(_translate("Settings", "
If this is unchecked, your project will be saved as a folder containing many small files.The Revisions feature has been at the source of many reported issues. In this version of Manuskript it has been turned off by default for new projects in order to provide the best experience.
Why aren\'t these issues fixed already? We need your help to make Manuskript better!
")) self.lblTitleViews.setText(_translate("Settings", "Views settings")) self.groupBox_3.setTitle(_translate("Settings", "Colors")) self.label_3.setText(_translate("Settings", "Icon color:")) @@ -1890,14 +1962,18 @@ class Ui_Settings(object): self.cmbTreeBackground.setItemText(4, _translate("Settings", "Compile")) self.groupBox_16.setTitle(_translate("Settings", "Icon Size")) self.lblTreeIconSize.setText(_translate("Settings", "TextLabel")) + self.horizontalGroupBox.setTitle(_translate("Settings", "Char/Word Counter")) + self.chkCountSpaces.setText(_translate("Settings", "Count spaces as chars")) self.groupBox_8.setTitle(_translate("Settings", "Folders")) self.rdoTreeItemCount.setText(_translate("Settings", "Show ite&m count")) self.rdoTreeWC.setText(_translate("Settings", "Show &word count")) + self.rdoTreeCC.setText(_translate("Settings", "Show char c&ount")) self.rdoTreeProgress.setText(_translate("Settings", "S&how progress")) self.rdoTreeSummary.setText(_translate("Settings", "Show summar&y")) self.rdoTreeNothing.setText(_translate("Settings", "&Nothing")) self.groupBox_9.setTitle(_translate("Settings", "Text")) self.rdoTreeTextWC.setText(_translate("Settings", "&Show word count")) + self.rdoTreeTextCC.setText(_translate("Settings", "Sho&w char count")) self.rdoTreeTextProgress.setText(_translate("Settings", "Show p&rogress")) self.rdoTreeTextSummary.setText(_translate("Settings", "Show summary")) self.rdoTreeTextNothing.setText(_translate("Settings", "Nothing")) @@ -2075,4 +2151,3 @@ class Ui_Settings(object): self.cmbThemeAlignment.setItemText(2, _translate("Settings", "Right")) self.cmbThemeAlignment.setItemText(3, _translate("Settings", "Justify")) self.label_53.setText(_translate("Settings", "Alignment")) - diff --git a/manuskript/ui/settings_ui.ui b/manuskript/ui/settings_ui.ui index dc745c4..6b1a658 100644 --- a/manuskript/ui/settings_ui.ui +++ b/manuskript/ui/settings_ui.ui @@ -6,7 +6,7 @@@@ -54,7 +54,7 @@ 0 0 -658 +681 598 - +
- 2 +0 @@ -407,7 +453,7 @@ @@ -98,93 +98,139 @@ Application settings - - -QFormLayout::FieldsStayAtSizeHint -- -
-- -- - -50 -false - -- -Style: -- -
-- -- - -50 -false - -- -
-- -- - -50 -false - -- -Language: -- -
-- -- - -50 -false - -- -
-- -- - -50 -false - -- -Font size: -- -
-- -- - -50 -false - -- -
- +- - -50 -false - -- -You might need to restart manuskript in order for those settings to take effect properly and entirely. -- -true -- +
+ - +
++ ++ + +50 +false + ++ +Restarting Manuskript ensures all settings take effect. ++ +true +- +
++ ++ +QLayout::SetDefaultConstraint +- +
++ ++ +QFormLayout::AllNonFixedFieldsGrow +- +
++ ++ + +50 +false + ++ +Style: +- +
++ ++ + +50 +false + +- +
++ ++ + +50 +false + ++ +Language: +- +
++ ++ + +50 +false + +- +
++ ++ + +50 +false + ++ +Font size: +- +
++ ++ + +50 +false + +- +
++ +- +
++ ++ + +50 +false + ++ +Show progress in chars next + to words +- +
++ ++ +Qt::Horizontal ++ ++ +40 +20 +- Save on quit +Save on project close true @@ -423,7 +469,7 @@- <html><head/><body><p>If you check this option, your project will be save as one single file. Easier to copy or backup, but does not allow collaborative editing, or versionning.<br/>If this is unchecked, your project will be save as a folder containing many small files.</p></body></html> +<html><head/><body><p>If you check this option, your project will be saved as one single file. Easier to copy or backup, but does not allow collaborative editing, or versioning.<br/>If this is unchecked, your project will be saved as a folder containing many small files.</p></body></html> @@ -578,6 +624,9 @@ + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + 1 +@@ -642,6 +691,9 @@ 999 + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + 1 +@@ -667,6 +719,9 @@ 999 + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + 1 +@@ -692,6 +747,9 @@ 999 + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + 1 +@@ -717,6 +775,9 @@ 999 + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + 1 +@@ -754,6 +815,19 @@ 999 - +
+ ++ +<p><b>The Revisions feature has been at the source of many reported issues. In this version of Manuskript it has been turned off by default for new projects in order to provide the best experience.</b></p><p>Why aren't these issues fixed already? <a href="https://www.theologeek.ch/manuskript/contribute/">We need your help to make Manuskript better!</a></p> ++ +true ++ +true +@@ -789,7 +863,7 @@ +- +
- 3 +0 @@ -1027,6 +1101,59 @@ - +
+ ++ + +75 +true + ++ +Char/Word Counter ++ ++ +9 ++ +9 ++ +9 ++ +9 +- +
++ ++ + +50 +false + ++ +Count spaces as chars +- +
++ ++ +Qt::Horizontal ++ ++ +40 +20 +- @@ -1067,6 +1194,19 @@
- +
+ ++ + +50 +false + ++ +Show char c&ount +- +
@@ -1137,6 +1277,19 @@ - +
+ ++ + +50 +false + ++ +Sho&w char count +- +
@@ -1196,6 +1349,11 @@ rdoTreeTextCC +rdoTreeTextWC +rdoTreeTextProgress +rdoTreeTextSummary +rdoTreeTextNothing - 1 +0 diff --git a/manuskript/ui/tools/frequency_ui.py b/manuskript/ui/tools/frequency_ui.py index cf2058e..29d105f 100644 --- a/manuskript/ui/tools/frequency_ui.py +++ b/manuskript/ui/tools/frequency_ui.py @@ -2,8 +2,7 @@ # Form implementation generated from reading ui file 'manuskript/ui/tools/frequency_ui.ui' # -# Created: Mon Feb 8 13:54:01 2016 -# by: PyQt5 UI code generator 5.2.1 +# Created by: PyQt5 UI code generator 5.5.1 # # WARNING! All changes made in this file will be lost! @@ -24,12 +23,11 @@ class Ui_FrequencyAnalyzer(object): self.splitter = QtWidgets.QSplitter(self.tab) self.splitter.setOrientation(QtCore.Qt.Horizontal) self.splitter.setObjectName("splitter") - self.widget = QtWidgets.QWidget(self.splitter) - self.widget.setObjectName("widget") - self.verticalLayout = QtWidgets.QVBoxLayout(self.widget) - self.verticalLayout.setContentsMargins(0, 0, 0, 0) + self.layoutWidget = QtWidgets.QWidget(self.splitter) + self.layoutWidget.setObjectName("layoutWidget") + self.verticalLayout = QtWidgets.QVBoxLayout(self.layoutWidget) self.verticalLayout.setObjectName("verticalLayout") - self.groupBox = QtWidgets.QGroupBox(self.widget) + self.groupBox = QtWidgets.QGroupBox(self.layoutWidget) self.groupBox.setObjectName("groupBox") self.formLayout = QtWidgets.QFormLayout(self.groupBox) self.formLayout.setFieldGrowthPolicy(QtWidgets.QFormLayout.AllNonFixedFieldsGrow) @@ -50,11 +48,11 @@ class Ui_FrequencyAnalyzer(object): self.txtWordExclude.setObjectName("txtWordExclude") self.formLayout.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.txtWordExclude) self.verticalLayout.addWidget(self.groupBox) - self.progressBarWord = QtWidgets.QProgressBar(self.widget) + self.progressBarWord = QtWidgets.QProgressBar(self.layoutWidget) self.progressBarWord.setProperty("value", 0) self.progressBarWord.setObjectName("progressBarWord") self.verticalLayout.addWidget(self.progressBarWord) - self.btnAnalyzeWord = QtWidgets.QPushButton(self.widget) + self.btnAnalyzeWord = QtWidgets.QPushButton(self.layoutWidget) self.btnAnalyzeWord.setObjectName("btnAnalyzeWord") self.verticalLayout.addWidget(self.btnAnalyzeWord) self.tblWord = QtWidgets.QTableView(self.splitter) @@ -97,7 +95,7 @@ class Ui_FrequencyAnalyzer(object): self.horizontalLayout.addWidget(self.tabWidget) self.retranslateUi(FrequencyAnalyzer) - self.tabWidget.setCurrentIndex(1) + self.tabWidget.setCurrentIndex(0) QtCore.QMetaObject.connectSlotsByName(FrequencyAnalyzer) def retranslateUi(self, FrequencyAnalyzer): diff --git a/manuskript/ui/tools/frequency_ui.ui b/manuskript/ui/tools/frequency_ui.ui index 5e34118..64e0b9b 100644 --- a/manuskript/ui/tools/frequency_ui.ui +++ b/manuskript/ui/tools/frequency_ui.ui @@ -17,7 +17,7 @@ - 1 +0 @@ -29,7 +29,7 @@ - Qt::Horizontal + diff --git a/manuskript/ui/views/MDEditCompleter.py b/manuskript/ui/views/MDEditCompleter.py index 5be00af..0101238 100644 --- a/manuskript/ui/views/MDEditCompleter.py +++ b/manuskript/ui/views/MDEditCompleter.py @@ -10,11 +10,6 @@ from manuskript.ui.editors.completer import completer from manuskript.ui.views.MDEditView import MDEditView from manuskript.models import references as Ref -try: - import enchant -except ImportError: - enchant = None - class MDEditCompleter(MDEditView): def __init__(self, parent=None, index=None, html=None, spellcheck=True, highlighting=False, dict="", @@ -111,13 +106,18 @@ class MDEditCompleter(MDEditView): self.completer.popup(self.textUnderCursor(select=True)) def mouseMoveEvent(self, event): + """ + When mouse moves, we show tooltip when appropriate. + """ + self.beginTooltipMoveEvent() MDEditView.mouseMoveEvent(self, event) + self.endTooltipMoveEvent() onRef = [r for r in self.refRects if r.contains(event.pos())] if not onRef: qApp.restoreOverrideCursor() - QToolTip.hideText() + self.hideTooltip() return cursor = self.cursorForPosition(event.pos()) @@ -125,7 +125,8 @@ class MDEditCompleter(MDEditView): if ref: if not qApp.overrideCursor(): qApp.setOverrideCursor(Qt.PointingHandCursor) - QToolTip.showText(self.mapToGlobal(event.pos()), Ref.tooltip(ref)) + + self.showTooltip(self.mapToGlobal(event.pos()), Ref.tooltip(ref)) def mouseReleaseEvent(self, event): MDEditView.mouseReleaseEvent(self, event) diff --git a/manuskript/ui/views/MDEditView.py b/manuskript/ui/views/MDEditView.py index 5ed2bd6..1efbc80 100644 --- a/manuskript/ui/views/MDEditView.py +++ b/manuskript/ui/views/MDEditView.py @@ -14,6 +14,8 @@ from manuskript.ui.highlighters.markdownEnums import MarkdownState as MS from manuskript.ui.highlighters.markdownTokenizer import MarkdownTokenizer as MT from manuskript import functions as F +import logging +LOGGER = logging.getLogger(__name__) class MDEditView(textEditView): @@ -23,7 +25,7 @@ class MDEditView(textEditView): imageRegex = QRegExp("!\\[([^\n]*)\\]\\(([^\n]+)\\)") automaticLinkRegex = QRegExp("(<([a-zA-Z]+\\:[^\n]+)>)|(<([^\n]+@[^\n]+)>)") - def __init__(self, parent=None, index=None, html=None, spellcheck=True, + def __init__(self, parent=None, index=None, html=None, spellcheck=None, highlighting=False, dict="", autoResize=False): textEditView.__init__(self, parent, index, html, spellcheck, highlighting=True, dict=dict, @@ -243,7 +245,7 @@ class MDEditView(textEditView): def comment(self): cursor = self.textCursor() - # Select begining and end of words + # Select beginning and end of words self.selectWord(cursor) if cursor.hasSelection(): @@ -294,7 +296,7 @@ class MDEditView(textEditView): def lineFormattingMarkup(self, markup): """ - Adds `markup` at the begining of block. + Adds `markup` at the beginning of block. """ cursor = self.textCursor() cursor.movePosition(cursor.StartOfBlock) @@ -303,7 +305,7 @@ class MDEditView(textEditView): def insertFormattingMarkup(self, markup): cursor = self.textCursor() - # Select begining and end of words + # Select beginning and end of words self.selectWord(cursor) if cursor.hasSelection(): @@ -498,7 +500,10 @@ class MDEditView(textEditView): r3.setLeft(self.viewport().geometry().left()) r3.setRight(self.viewport().geometry().right()) refs.append(ClickThing(r3, rx, rx.capturedTexts())) - cursor.movePosition(cursor.Down) + if not cursor.movePosition(cursor.Down): + # Super-rare failure. Leaving log message for future investigation. + LOGGER.debug("Failed to move cursor down while calculating clickables. Aborting.") + break self.clickRects = refs @@ -506,13 +511,15 @@ class MDEditView(textEditView): """ When mouse moves, we show tooltip when appropriate. """ + self.beginTooltipMoveEvent() textEditView.mouseMoveEvent(self, event) + self.endTooltipMoveEvent() onRect = [r for r in self.clickRects if r.rect.contains(event.pos())] if not onRect: qApp.restoreOverrideCursor() - QToolTip.hideText() + self.hideTooltip() return ct = onRect[0] @@ -527,14 +534,14 @@ class MDEditView(textEditView): +"") tooltip = None pos = event.pos() + QPoint(0, ct.rect.height()) - imageTooltiper.fromUrl(ct.texts[2], pos, self) + ImageTooltip.fromUrl(ct.texts[2], pos, self) elif ct.regex == self.inlineLinkRegex: tooltip = ct.texts[1] or ct.texts[2] if tooltip: tooltip = self.tr("{} (CTRL+Click to open)").format(tooltip) - QToolTip.showText(self.mapToGlobal(event.pos()), tooltip) + self.showTooltip(self.mapToGlobal(event.pos()), tooltip) def mouseReleaseEvent(self, event): textEditView.mouseReleaseEvent(self, event) @@ -582,53 +589,127 @@ from PyQt5.QtNetwork import QNetworkRequest, QNetworkAccessManager, QNetworkRepl from PyQt5.QtCore import QIODevice, QUrl, QBuffer from PyQt5.QtGui import QPixmap -class imageTooltiper: +class ImageTooltip: + """ + This class handles the retrieving and caching of images in order to display these in tooltips. + """ cache = {} manager = QNetworkAccessManager() - data = {} + processing = {} + + supportedSchemes = ("", "file", "http", "https") def fromUrl(url, pos, editor): - cache = imageTooltiper.cache - imageTooltiper.editor = editor + """ + Shows the image tooltip for the given url if available, or requests it for future use. + """ + ImageTooltip.editor = editor - if url in cache: - if not cache[url][0]: # error, image was not found - imageTooltiper.tooltipError(cache[url][1], pos) - else: - imageTooltiper.tooltip(cache[url][1], pos) - return + if ImageTooltip.showTooltip(url, pos): + return # the url already exists in the cache try: - imageTooltiper.manager.finished.connect(imageTooltiper.finished, F.AUC) + ImageTooltip.manager.finished.connect(ImageTooltip.finished, F.AUC) except: - pass + pass # already connected - request = QNetworkRequest(QUrl(url)) - imageTooltiper.data[QUrl(url)] = (pos, url) - imageTooltiper.manager.get(request) + qurl = QUrl.fromUserInput(url) + if (qurl == QUrl()): + ImageTooltip.cache[url] = (False, ImageTooltip.manager.tr("The image path or URL is incomplete or malformed.")) + ImageTooltip.showTooltip(url, pos) + return # empty QUrl means it failed completely + elif (qurl.scheme() not in ImageTooltip.supportedSchemes): + # QUrl.fromUserInput() can occasionally deduce an incorrect scheme, + # which produces an error message regarding an unknown scheme. (Yay!) + # But it also breaks all possible methods to try and associate the + # reply with the original request in finished(), since reply.request() + # is completely and utterly butchered for all tracking needs. :'( + # (The QNetworkRequest, .url() and .originatingObject() can all change.) + + # Test case (Linux): ![image](C:\test_root.jpg) + ImageTooltip.cache[url] = (False, ImageTooltip.manager.tr("The protocol \"{}\" is not supported.").format(qurl.scheme())) + ImageTooltip.showTooltip(url, pos) + return # no more request/reply chaos, please! + elif (qurl in ImageTooltip.processing): + return # one download is more than enough + + # Request the image for later processing. + request = QNetworkRequest(qurl) + ImageTooltip.processing[qurl] = (pos, url) + reply = ImageTooltip.manager.get(request) + + # On Linux the finished() signal is not triggered when the url resembles + # 'file://X:/...'. But because it completes instantly, we can manually + # trigger the code to keep our processing dictionary neat & clean. + if reply.error() == 302: # QNetworkReply.ProtocolInvalidOperationError + ImageTooltip.finished(reply) def finished(reply): - cache = imageTooltiper.cache - pos, url = imageTooltiper.data[reply.url()] + """ + After retrieving an image, we add it to the cache. + """ + cache = ImageTooltip.cache + url_key = reply.request().url() + pos, url = None, None + + if url_key in ImageTooltip.processing: + # Obtain the information associated with this request. + pos, url = ImageTooltip.processing[url_key] + del ImageTooltip.processing[url_key] + elif len(ImageTooltip.processing) == 0: + # We are not processing anything. Maybe it is a spurious signal, + # or maybe the 'reply.error() == 302' workaround in fromUrl() has + # been fixed in Qt. Whatever the reason, we can assume this request + # has already been handled, and needs no more work from us. + return + else: + # Somehow we lost track. Log what we can to hopefully figure it out. + LOGGER.warning("Unable to match fetched data for tooltip to original request.") + LOGGER.warning("- Completed request: %s", url_key) + LOGGER.warning("- Status upon finishing: %s, %s", reply.error(), reply.errorString()) + LOGGER.warning("- Currently processing: %s", ImageTooltip.processing) + return + + # Update cache with retrieved data. if reply.error() != QNetworkReply.NoError: cache[url] = (False, reply.errorString()) - imageTooltiper.tooltipError(reply.errorString(), pos) else: px = QPixmap() px.loadFromData(reply.readAll()) px = px.scaled(800, 600, Qt.KeepAspectRatio) cache[url] = (True, px) - imageTooltiper.tooltip(px, pos) + + ImageTooltip.showTooltip(url, pos) + + def showTooltip(url, pos): + """ + Show a tooltip for the given url based on cached information. + """ + cache = ImageTooltip.cache + + if url in cache: + if not cache[url][0]: # error, image was not found + ImageTooltip.tooltipError(cache[url][1], pos) + else: + ImageTooltip.tooltip(cache[url][1], pos) + return True + return False def tooltipError(message, pos): - imageTooltiper.editor.doTooltip(pos, message) + """ + Display a tooltip with an error message at the given position. + """ + ImageTooltip.editor.doTooltip(pos, message) def tooltip(image, pos): + """ + Display a tooltip with an image at the given position. + """ px = image buffer = QBuffer() buffer.open(QIODevice.WriteOnly) px.save(buffer, "PNG", quality=100) image = bytes(buffer.data().toBase64()).decode() tt = "".format(image) - imageTooltiper.editor.doTooltip(pos, tt) + ImageTooltip.editor.doTooltip(pos, tt) diff --git a/manuskript/ui/views/basicItemView_ui.py b/manuskript/ui/views/basicItemView_ui.py index 91e0912..d292d13 100644 --- a/manuskript/ui/views/basicItemView_ui.py +++ b/manuskript/ui/views/basicItemView_ui.py @@ -2,12 +2,15 @@ # Form implementation generated from reading ui file 'manuskript/ui/views/basicItemView_ui.ui' # -# Created by: PyQt5 UI code generator 5.5.1 +# Created by: PyQt5 UI code generator 5.15.5 # -# WARNING! All changes made in this file will be lost! +# WARNING: Any manual changes made to this file will be lost when pyuic5 is +# run again. Do not edit this file unless you know what you are doing. + from PyQt5 import QtCore, QtGui, QtWidgets + class Ui_basicItemView(object): def setupUi(self, basicItemView): basicItemView.setObjectName("basicItemView") @@ -65,7 +68,6 @@ class Ui_basicItemView(object): self.txtGoal.setPlaceholderText(_translate("basicItemView", "Word count")) self.txtSummarySentence.setPlaceholderText(_translate("basicItemView", "One line summary")) self.label_9.setText(_translate("basicItemView", "Few sentences summary:")) - from manuskript.ui.views.MDEditCompleter import MDEditCompleter from manuskript.ui.views.cmbOutlineCharacterChoser import cmbOutlineCharacterChoser from manuskript.ui.views.lineEditView import lineEditView diff --git a/manuskript/ui/views/characterTreeView.py b/manuskript/ui/views/characterTreeView.py index 6997f90..2bcc9f9 100644 --- a/manuskript/ui/views/characterTreeView.py +++ b/manuskript/ui/views/characterTreeView.py @@ -29,6 +29,8 @@ class characterTreeView(QTreeWidget): self._rootItem = QTreeWidgetItem() self.insertTopLevelItem(0, self._rootItem) + self.importanceMap = {self.tr("Main"):2, self.tr("Secondary"):1, self.tr("Minor"):0} + def setCharactersModel(self, model): self._model = model self._model.dataChanged.connect(self.updateMaybe) @@ -64,7 +66,7 @@ class characterTreeView(QTreeWidget): for child in range(item.childCount()): sub = item.child(child) ID = sub.data(0, Qt.UserRole) - if ID is not None: + if ID != None: # Update name c = self._model.getCharacterByID(ID) name = c.name() @@ -86,11 +88,9 @@ class characterTreeView(QTreeWidget): self.clear() characters = self._model.getCharactersByImportance() - h = [self.tr("Main"), self.tr("Secondary"), self.tr("Minor")] - - for i in range(3): + for i, importanceLevel in enumerate(self.importanceMap): # Create category item - cat = QTreeWidgetItem(self, [h[i]]) + cat = QTreeWidgetItem(self, [importanceLevel]) cat.setBackground(0, QBrush(QColor(S.highlightLight))) cat.setForeground(0, QBrush(QColor(S.highlightedTextDark))) cat.setTextAlignment(0, Qt.AlignCenter) @@ -119,33 +119,61 @@ class characterTreeView(QTreeWidget): self.expandAll() self._updating = False + def addCharacter(self): + curr_item = self.currentItem() + curr_importance = 0 + + # check if an item is selected + if curr_item != None: + if curr_item.parent() == None: + # this is a top-level category, so find its importance + # get the current text, then look up the importance level + text = curr_item.text(0) + curr_importance = self.importanceMap[text] + else: + # get the importance from the currently-highlighted character + curr_character = self.currentCharacter() + curr_importance = curr_character.importance() + + self._model.addCharacter(importance=curr_importance) + def removeCharacter(self): """ Removes selected character. """ ID = self.currentCharacterID() - if ID: - self._model.removeCharacter(ID) + if ID is None: + return None + self._model.removeCharacter(ID) + return ID def choseCharacterColor(self): ID = self.currentCharacterID() c = self._model.getCharacterByID(ID) + if c: color = iconColor(c.icon) else: color = Qt.white + self.colorDialog = QColorDialog(color, mainWindow()) color = self.colorDialog.getColor(color) + if color.isValid(): c.setColor(color) mainWindow().updateCharacterColor(ID) + def changeCharacterPOVState(self, state): + ID = self.currentCharacterID() + c = self._model.getCharacterByID(ID) + c.setPOVEnabled(state == Qt.Checked) + mainWindow().updateCharacterPOVState(ID) + def addCharacterInfo(self): self._model.addCharacterInfo(self.currentCharacterID()) def removeCharacterInfo(self): - self._model.removeCharacterInfo(self.currentCharacterID(), - ) + self._model.removeCharacterInfo(self.currentCharacterID()) def currentCharacterID(self): ID = None @@ -171,6 +199,8 @@ class characterTreeView(QTreeWidget): def mouseDoubleClickEvent(self, event): item = self.currentItem() + if item is None: + return # Catching double clicks to forbid collapsing of toplevel items if item.parent(): QTreeWidget.mouseDoubleClickEvent(self, event) diff --git a/manuskript/ui/views/corkDelegate.py b/manuskript/ui/views/corkDelegate.py index 70ff19e..fc63c28 100644 --- a/manuskript/ui/views/corkDelegate.py +++ b/manuskript/ui/views/corkDelegate.py @@ -43,11 +43,19 @@ class corkDelegate(QStyledItemDelegate): return QStyledItemDelegate.editorEvent(self, event, model, option, index) def createEditor(self, parent, option, index): + # When the user performs a global search and selects an Outline result (title or summary), the + # associated chapter is selected in cork view, triggering a call to this method with the results + # list widget set in self.sender(). In this case we store the searched column so we know which + # editor should be created. + searchedColumn = None + if self.sender() is not None and self.sender().objectName() == 'result' and self.sender().currentItem(): + searchedColumn = self.sender().currentItem().data(Qt.UserRole).column() + self.updateRects(option, index) bgColor = self.bgColors.get(index, "white") - if self.mainLineRect.contains(self.lastPos): + if searchedColumn == Outline.summarySentence or (self.lastPos is not None and self.mainLineRect.contains(self.lastPos)): # One line summary self.editing = Outline.summarySentence edt = QLineEdit(parent) @@ -64,7 +72,7 @@ class corkDelegate(QStyledItemDelegate): edt.setStyleSheet("background: {}; color: black;".format(bgColor)) return edt - elif self.titleRect.contains(self.lastPos): + elif searchedColumn == Outline.title or (self.lastPos is not None and self.titleRect.contains(self.lastPos)): # Title self.editing = Outline.title edt = QLineEdit(parent) @@ -145,15 +153,15 @@ class corkDelegate(QStyledItemDelegate): self.updateRects_v1(option, index) def updateRects_v2(self, option, index): - margin = self.margin * 2 - iconSize = max(24 * self.factor, 18) + margin = int(self.margin * 2) + iconSize = int(max(24 * self.factor, 18)) item = index.internalPointer() fm = QFontMetrics(option.font) - h = fm.lineSpacing() + h = int(fm.lineSpacing()) self.itemRect = option.rect.adjusted(margin, margin, -margin, -margin) - top = 15 * self.factor + top = int(15 * self.factor) self.topRect = QRect(self.itemRect) self.topRect.setHeight(top) @@ -161,8 +169,8 @@ class corkDelegate(QStyledItemDelegate): self.itemRect.bottomRight()) self.iconRect = QRect(self.cardRect.topLeft() + QPoint(margin, margin), QSize(iconSize, iconSize)) - self.labelRect = QRect(self.cardRect.topRight() - QPoint(margin + self.factor * 18, 1), - self.cardRect.topRight() + QPoint(- margin - self.factor * 4, self.factor * 24)) + self.labelRect = QRect(self.cardRect.topRight() - QPoint(int(margin + self.factor * 18), 1), + self.cardRect.topRight() + QPoint(int(-margin - self.factor * 4), int(self.factor * 24))) self.titleRect = QRect(self.iconRect.topRight() + QPoint(margin, 0), self.labelRect.bottomLeft() - QPoint(margin, margin)) self.titleRect.setBottom(self.iconRect.bottom()) @@ -177,8 +185,8 @@ class corkDelegate(QStyledItemDelegate): self.mainTextRect.setTopLeft(self.mainLineRect.topLeft()) def updateRects_v1(self, option, index): - margin = self.margin - iconSize = max(16 * self.factor, 12) + margin = int(self.margin) + iconSize = int(max(16 * self.factor, 12)) item = index.internalPointer() self.itemRect = option.rect.adjusted(margin, margin, -margin, -margin) self.iconRect = QRect(self.itemRect.topLeft() + QPoint(margin, margin), QSize(iconSize, iconSize)) @@ -262,8 +270,8 @@ class corkDelegate(QStyledItemDelegate): if item.isFolder(): itemPoly = QPolygonF([ self.topRect.topLeft(), - self.topRect.topLeft() + QPoint(self.topRect.width() * .35, 0), - self.cardRect.topLeft() + QPoint(self.topRect.width() * .45, 0), + self.topRect.topLeft() + QPoint(int(self.topRect.width() * .35), 0), + self.cardRect.topLeft() + QPoint(int(self.topRect.width() * .45), 0), self.cardRect.topRight(), self.cardRect.bottomRight(), self.cardRect.bottomLeft() @@ -480,7 +488,7 @@ class corkDelegate(QStyledItemDelegate): fullSummary = item.data(Outline.summaryFull) if lineSummary or not fullSummary: m = self.margin - r = self.mainLineRect.adjusted(-m, -m, m, m / 2) + r = self.mainLineRect.adjusted(-m, -m, m, int(m / 2)) p.save() p.setPen(Qt.NoPen) p.setBrush(QColor("#EEE")) diff --git a/manuskript/ui/views/corkView.py b/manuskript/ui/views/corkView.py index c27c3cb..864a02a 100644 --- a/manuskript/ui/views/corkView.py +++ b/manuskript/ui/views/corkView.py @@ -27,6 +27,8 @@ class corkView(QListView, dndView, outlineBasics): def updateBackground(self): if settings.corkBackground["image"] != "": img = findBackground(settings.corkBackground["image"]) + if img == None: + img = "" else: # No background image img = "" @@ -36,7 +38,7 @@ class corkView(QListView, dndView, outlineBasics): background-attachment: fixed; }}""".format( color=settings.corkBackground["color"], - url=img + url=img.replace("\\", "/") )) def dragMoveEvent(self, event): diff --git a/manuskript/ui/views/dndView.py b/manuskript/ui/views/dndView.py index c028964..b2abf08 100644 --- a/manuskript/ui/views/dndView.py +++ b/manuskript/ui/views/dndView.py @@ -13,7 +13,6 @@ class dndView(QAbstractItemView): def dragMoveEvent(self, event): # return QAbstractItemView.dragMoveEvent(self, event) - # print(a) if event.keyboardModifiers() & Qt.ControlModifier: event.setDropAction(Qt.CopyAction) else: diff --git a/manuskript/ui/views/lineEditView.py b/manuskript/ui/views/lineEditView.py index 1df64c5..b61042f 100644 --- a/manuskript/ui/views/lineEditView.py +++ b/manuskript/ui/views/lineEditView.py @@ -1,5 +1,6 @@ #!/usr/bin/env python # --!-- coding: utf8 --!-- +from PyQt5.QtCore import QMutex from PyQt5.QtWidgets import QLineEdit from manuskript.enums import Outline @@ -13,7 +14,7 @@ class lineEditView(QLineEdit): self._indexes = None self._index = None self._placeholderText = None - self._updating = False + self._updating = QMutex() def setModel(self, model): self._model = model @@ -30,7 +31,7 @@ class lineEditView(QLineEdit): self._index = index self._model = index.model() # self.item = index.internalPointer() - if self._placeholderText is not None: + if self._placeholderText != None: self.setPlaceholderText(self._placeholderText) self.textEdited.connect(self.submit) self.updateText() @@ -49,38 +50,39 @@ class lineEditView(QLineEdit): self.updateText() def submit(self): + self._updating.lock() + text = self.text() + self._updating.unlock() + if self._index: # item = self._index.internalPointer() - if self.text() != self._model.data(self._index): - self._model.setData(self._index, self.text()) + if text != self._model.data(self._index): + self._model.setData(self._index, text) elif self._indexes: - self._updating = True for i in self._indexes: # item = i.internalPointer() - if self.text() != self._model.data(i): - self._model.setData(i, self.text()) - self._updating = False + if text != self._model.data(i): + self._model.setData(i, text) def update(self, topLeft, bottomRight): - - if self._updating: - # We are currently putting data in the model, so no updates - return + update = False if self._index: if topLeft.row() <= self._index.row() <= bottomRight.row(): - self.updateText() + update = True elif self._indexes: - update = False for i in self._indexes: if topLeft.row() <= i.row() <= bottomRight.row(): update = True - if update: - self.updateText() + + if update: + self.updateText() def updateText(self): + self._updating.lock() + if self._index: # item = self._index.internalPointer() # txt = toString(item.data(self._column)) @@ -110,3 +112,6 @@ class lineEditView(QLineEdit): self._placeholderText = self.placeholderText() self.setPlaceholderText(self.tr("Various")) + + self._updating.unlock() + diff --git a/manuskript/ui/views/outlineBasics.py b/manuskript/ui/views/outlineBasics.py index 2dfa9ff..5f7ea89 100644 --- a/manuskript/ui/views/outlineBasics.py +++ b/manuskript/ui/views/outlineBasics.py @@ -9,7 +9,7 @@ from PyQt5.QtWidgets import QAbstractItemView, qApp, QMenu, QAction, \ from manuskript import settings from manuskript.enums import Outline from manuskript.functions import mainWindow, statusMessage -from manuskript.functions import toInt, customIcons +from manuskript.functions import toInt, customIcons, safeTranslate from manuskript.models import outlineItem from manuskript.ui.tools.splitDialog import splitDialog @@ -58,24 +58,24 @@ class outlineBasics(QAbstractItemView): title = mouseIndex.internalPointer().title() else: - title = qApp.translate("outlineBasics", "Root") + title = safeTranslate(qApp, "outlineBasics", "Root") if len(title) > 25: title = title[:25] + "…" # Open Item action self.actOpen = QAction(QIcon.fromTheme("go-right"), - qApp.translate("outlineBasics", "Open {}".format(title)), + safeTranslate(qApp, "outlineBasics", "Open {}".format(title)), menu) self.actOpen.triggered.connect(self.openItem) menu.addAction(self.actOpen) # Open item(s) in new tab if mouseIndex in sel and len(sel) > 1: - actionTitle = qApp.translate("outlineBasics", "Open {} items in new tabs").format(len(sel)) + actionTitle = safeTranslate(qApp, "outlineBasics", "Open {} items in new tabs").format(len(sel)) self._indexesToOpen = sel else: - actionTitle = qApp.translate("outlineBasics", "Open {} in a new tab").format(title) + actionTitle = safeTranslate(qApp, "outlineBasics", "Open {} in a new tab").format(title) self._indexesToOpen = [mouseIndex] self.actNewTab = QAction(QIcon.fromTheme("go-right"), actionTitle, menu) @@ -86,13 +86,13 @@ class outlineBasics(QAbstractItemView): # Add text / folder self.actAddFolder = QAction(QIcon.fromTheme("folder-new"), - qApp.translate("outlineBasics", "New &Folder"), + safeTranslate(qApp, "outlineBasics", "New &Folder"), menu) self.actAddFolder.triggered.connect(self.addFolder) menu.addAction(self.actAddFolder) self.actAddText = QAction(QIcon.fromTheme("document-new"), - qApp.translate("outlineBasics", "New &Text"), + safeTranslate(qApp, "outlineBasics", "New &Text"), menu) self.actAddText.triggered.connect(self.addText) menu.addAction(self.actAddText) @@ -101,29 +101,29 @@ class outlineBasics(QAbstractItemView): # Copy, cut, paste, duplicate self.actCut = QAction(QIcon.fromTheme("edit-cut"), - qApp.translate("outlineBasics", "C&ut"), menu) + safeTranslate(qApp, "outlineBasics", "C&ut"), menu) self.actCut.triggered.connect(self.cut) menu.addAction(self.actCut) self.actCopy = QAction(QIcon.fromTheme("edit-copy"), - qApp.translate("outlineBasics", "&Copy"), menu) + safeTranslate(qApp, "outlineBasics", "&Copy"), menu) self.actCopy.triggered.connect(self.copy) menu.addAction(self.actCopy) self.actPaste = QAction(QIcon.fromTheme("edit-paste"), - qApp.translate("outlineBasics", "&Paste"), menu) + safeTranslate(qApp, "outlineBasics", "&Paste"), menu) self.actPaste.triggered.connect(self.paste) menu.addAction(self.actPaste) # Rename / duplicate / remove items self.actDelete = QAction(QIcon.fromTheme("edit-delete"), - qApp.translate("outlineBasics", "&Delete"), + safeTranslate(qApp, "outlineBasics", "&Delete"), menu) self.actDelete.triggered.connect(self.delete) menu.addAction(self.actDelete) self.actRename = QAction(QIcon.fromTheme("edit-rename"), - qApp.translate("outlineBasics", "&Rename"), + safeTranslate(qApp, "outlineBasics", "&Rename"), menu) self.actRename.triggered.connect(self.rename) menu.addAction(self.actRename) @@ -131,17 +131,17 @@ class outlineBasics(QAbstractItemView): menu.addSeparator() # POV - self.menuPOV = QMenu(qApp.translate("outlineBasics", "Set POV"), menu) + self.menuPOV = QMenu(safeTranslate(qApp, "outlineBasics", "Set POV"), menu) mw = mainWindow() - a = QAction(QIcon.fromTheme("dialog-no"), qApp.translate("outlineBasics", "None"), self.menuPOV) + a = QAction(QIcon.fromTheme("dialog-no"), safeTranslate(qApp, "outlineBasics", "None"), self.menuPOV) a.triggered.connect(lambda: self.setPOV("")) self.menuPOV.addAction(a) self.menuPOV.addSeparator() menus = [] - for i in [qApp.translate("outlineBasics", "Main"), - qApp.translate("outlineBasics", "Secondary"), - qApp.translate("outlineBasics", "Minor")]: + for i in [safeTranslate(qApp, "outlineBasics", "Main"), + safeTranslate(qApp, "outlineBasics", "Secondary"), + safeTranslate(qApp, "outlineBasics", "Minor")]: m = QMenu(i, self.menuPOV) menus.append(m) self.menuPOV.addMenu(m) @@ -160,8 +160,8 @@ class outlineBasics(QAbstractItemView): menu.addMenu(self.menuPOV) # Status - self.menuStatus = QMenu(qApp.translate("outlineBasics", "Set Status"), menu) - # a = QAction(QIcon.fromTheme("dialog-no"), qApp.translate("outlineBasics", "None"), self.menuStatus) + self.menuStatus = QMenu(safeTranslate(qApp, "outlineBasics", "Set Status"), menu) + # a = QAction(QIcon.fromTheme("dialog-no"), safeTranslate(qApp, "outlineBasics", "None"), self.menuStatus) # a.triggered.connect(lambda: self.setStatus("")) # self.menuStatus.addAction(a) # self.menuStatus.addSeparator() @@ -176,7 +176,7 @@ class outlineBasics(QAbstractItemView): menu.addMenu(self.menuStatus) # Labels - self.menuLabel = QMenu(qApp.translate("outlineBasics", "Set Label"), menu) + self.menuLabel = QMenu(safeTranslate(qApp, "outlineBasics", "Set Label"), menu) mpr = QSignalMapper(self.menuLabel) for i in range(mw.mdlLabels.rowCount()): a = QAction(mw.mdlLabels.item(i, 0).icon(), @@ -194,8 +194,8 @@ class outlineBasics(QAbstractItemView): if self.menuCustomIcons: menu.addMenu(self.menuCustomIcons) else: - self.menuCustomIcons = QMenu(qApp.translate("outlineBasics", "Set Custom Icon"), menu) - a = QAction(qApp.translate("outlineBasics", "Restore to default"), self.menuCustomIcons) + self.menuCustomIcons = QMenu(safeTranslate(qApp, "outlineBasics", "Set Custom Icon"), menu) + a = QAction(safeTranslate(qApp, "outlineBasics", "Restore to default"), self.menuCustomIcons) a.triggered.connect(lambda: self.setCustomIcon("")) self.menuCustomIcons.addAction(a) self.menuCustomIcons.addSeparator() @@ -203,7 +203,7 @@ class outlineBasics(QAbstractItemView): txt = QLineEdit() txt.textChanged.connect(self.filterLstIcons) txt.setPlaceholderText("Filter icons") - txt.setStyleSheet("background: transparent; border: none;") + txt.setStyleSheet("QLineEdit { background: transparent; border: none; }") act = QWidgetAction(self.menuCustomIcons) act.setDefaultWidget(txt) self.menuCustomIcons.addAction(act) @@ -280,7 +280,7 @@ class outlineBasics(QAbstractItemView): if _type == "text": _type = settings.defaultTextType - item = outlineItem(title=qApp.translate("outlineBasics", "New"), _type=_type) + item = outlineItem(title=safeTranslate(qApp, "outlineBasics", "New"), _type=_type) self.model().appendItem(item, parent) def copy(self): @@ -310,11 +310,24 @@ class outlineBasics(QAbstractItemView): Shows a warning, and then deletes currently selected indexes. """ if not settings.dontShowDeleteWarning: + msgInfo = list() + msgInfo.append(" ") + msgInfo.append(safeTranslate(qApp, "outlineBasics", "You're about to delete {} item(s).").format( + len(self.getSelection()) + )) + + msgInfo.append("
") + for i in self.getSelection(): + title = self.model().data(i.sibling(i.row(), Outline.title)) + msgInfo.append("
- {}
".format(str(title))) + + msgInfo.append("") + msgInfo.append(safeTranslate(qApp, "outlineBasics", "Are you sure?")) + msgInfo.append("
") + msg = QMessageBox(QMessageBox.Warning, - qApp.translate("outlineBasics", "About to remove"), - qApp.translate("outlineBasics", - "You're about to delete {} item(s).
Are you sure?
" - ).format(len(self.getSelection())), + safeTranslate(qApp, "outlineBasics", "About to remove"), + "".join(msgInfo), QMessageBox.Yes | QMessageBox.Cancel) chk = QCheckBox("&Don't show this warning in the future.") @@ -383,7 +396,7 @@ class outlineBasics(QAbstractItemView): parentItem.childItems.insert(index.row() + delta, parentItem.childItems.pop(index.row())) - parentItem.updateWordCount(emit=False) + parentItem.updateWordCount() def moveUp(self): self.move(-1) def moveDown(self): self.move(+1) @@ -418,7 +431,7 @@ class outlineBasics(QAbstractItemView): # Check that we have at least 2 items if len(items) < 2: - statusMessage(qApp.translate("outlineBasics", + statusMessage(safeTranslate(qApp, "outlineBasics", "Select at least two items. Folders are ignored."), importance=2) return @@ -427,7 +440,7 @@ class outlineBasics(QAbstractItemView): p = items[0].parent() for i in items: if i.parent() != p: - statusMessage(qApp.translate("outlineBasics", + statusMessage(safeTranslate(qApp, "outlineBasics", "All items must be on the same level (share the same parent)."), importance=2) return diff --git a/manuskript/ui/views/outlineDelegates.py b/manuskript/ui/views/outlineDelegates.py index 48bba8e..1a9cfa4 100644 --- a/manuskript/ui/views/outlineDelegates.py +++ b/manuskript/ui/views/outlineDelegates.py @@ -237,11 +237,11 @@ class outlineGoalPercentageDelegate(QStyledItemDelegate): rect = option.rect.adjusted(margin, margin, -margin, -margin) # Move - rect.translate(level * rect.width() / 10, 0) - rect.setWidth(rect.width() - level * rect.width() / 10) + rect.translate(int(level * rect.width() / 10), 0) + rect.setWidth(int(rect.width() - level * rect.width() / 10)) - rect.setHeight(height) - rect.setTop(option.rect.top() + (option.rect.height() - height) / 2) + rect.setHeight(int(height)) + rect.setTop(int(option.rect.top() + (option.rect.height() - height) / 2)) drawProgress(painter, rect, p) # from functions @@ -313,7 +313,7 @@ class outlineLabelDelegate(QStyledItemDelegate): idx = self.mdlLabels.indexFromItem(item) opt = QStyleOptionViewItem(option) self.initStyleOption(opt, idx) - s = qApp.style().sizeFromContents(QStyle.CT_ItemViewItem, opt, QSize()) + s = qApp.style().sizeFromContents(QStyle.CT_ItemViewItem, opt, QSize(), None) if s.width() > 150: s.setWidth(150) elif s.width() < 50: diff --git a/manuskript/ui/views/plotTreeView.py b/manuskript/ui/views/plotTreeView.py index 9a592d9..97c46fc 100644 --- a/manuskript/ui/views/plotTreeView.py +++ b/manuskript/ui/views/plotTreeView.py @@ -200,6 +200,9 @@ class plotTreeView(QTreeWidget): def mouseDoubleClickEvent(self, event): item = self.currentItem() + if not item: + return + # Catching double clicks to forbid collapsing of toplevel items if item.parent(): QTreeWidget.mouseDoubleClickEvent(self, event) diff --git a/manuskript/ui/views/propertiesView.py b/manuskript/ui/views/propertiesView.py index 861dc74..0c5f95b 100644 --- a/manuskript/ui/views/propertiesView.py +++ b/manuskript/ui/views/propertiesView.py @@ -5,7 +5,10 @@ from PyQt5.QtGui import QIntValidator from manuskript.enums import Outline from manuskript.ui.views.propertiesView_ui import Ui_propertiesView +from manuskript.models.characterPOVModel import characterPOVModel +import logging +LOGGER = logging.getLogger(__name__) class propertiesView(QWidget, Ui_propertiesView): def __init__(self, parent=None): @@ -14,7 +17,7 @@ class propertiesView(QWidget, Ui_propertiesView): self.txtGoal.setColumn(Outline.setGoal) def setModels(self, mdlOutline, mdlCharacter, mdlLabels, mdlStatus): - self.cmbPOV.setModels(mdlCharacter, mdlOutline) + self.cmbPOV.setModels(characterPOVModel(mdlCharacter), mdlOutline) self.cmbLabel.setModels(mdlLabels, mdlOutline) self.cmbStatus.setModels(mdlStatus, mdlOutline) self.chkCompile.setModel(mdlOutline) @@ -38,7 +41,7 @@ class propertiesView(QWidget, Ui_propertiesView): def selectionChanged(self, sourceView): indexes = self.getIndexes(sourceView) - # print(indexes) + # LOGGER.debug("selectionChanged indexes: %s", indexes) if len(indexes) == 0: self.setEnabled(False) diff --git a/manuskript/ui/views/propertiesView_ui.py b/manuskript/ui/views/propertiesView_ui.py index e4846fa..c003e15 100644 --- a/manuskript/ui/views/propertiesView_ui.py +++ b/manuskript/ui/views/propertiesView_ui.py @@ -2,12 +2,15 @@ # Form implementation generated from reading ui file 'manuskript/ui/views/propertiesView_ui.ui' # -# Created by: PyQt5 UI code generator 5.9 +# Created by: PyQt5 UI code generator 5.15.7 # -# WARNING! All changes made in this file will be lost! +# WARNING: Any manual changes made to this file will be lost when pyuic5 is +# run again. Do not edit this file unless you know what you are doing. + from PyQt5 import QtCore, QtGui, QtWidgets + class Ui_propertiesView(object): def setupUi(self, propertiesView): propertiesView.setObjectName("propertiesView") @@ -20,7 +23,7 @@ class Ui_propertiesView(object): font.setBold(True) font.setWeight(75) self.txtTitle.setFont(font) - self.txtTitle.setStyleSheet("background:transparent;") + self.txtTitle.setStyleSheet("QLineEdit { background:transparent; }") self.txtTitle.setFrame(False) self.txtTitle.setObjectName("txtTitle") self.verticalLayout.addWidget(self.txtTitle) @@ -180,7 +183,6 @@ class Ui_propertiesView(object): self.label_35.setText(_translate("propertiesView", "Compile")) self.label_36.setText(_translate("propertiesView", "Goal")) self.txtGoalMulti.setPlaceholderText(_translate("propertiesView", "Word count")) - from manuskript.ui.views.chkOutlineCompile import chkOutlineCompile from manuskript.ui.views.cmbOutlineCharacterChoser import cmbOutlineCharacterChoser from manuskript.ui.views.cmbOutlineLabelChoser import cmbOutlineLabelChoser diff --git a/manuskript/ui/views/propertiesView_ui.ui b/manuskript/ui/views/propertiesView_ui.ui index 4e1549f..9f7d601 100644 --- a/manuskript/ui/views/propertiesView_ui.ui +++ b/manuskript/ui/views/propertiesView_ui.ui @@ -35,7 +35,7 @@- background:transparent; +QLineEdit { background:transparent; } false diff --git a/manuskript/ui/views/sldImportance_ui.py b/manuskript/ui/views/sldImportance_ui.py index 639bbdc..b8d3d94 100644 --- a/manuskript/ui/views/sldImportance_ui.py +++ b/manuskript/ui/views/sldImportance_ui.py @@ -2,13 +2,15 @@ # Form implementation generated from reading ui file 'manuskript/ui/views/sldImportance_ui.ui' # -# Created: Thu Mar 3 18:52:22 2016 -# by: PyQt5 UI code generator 5.2.1 +# Created by: PyQt5 UI code generator 5.15.5 # -# WARNING! All changes made in this file will be lost! +# WARNING: Any manual changes made to this file will be lost when pyuic5 is +# run again. Do not edit this file unless you know what you are doing. + from PyQt5 import QtCore, QtGui, QtWidgets + class Ui_sldImportance(object): def setupUi(self, sldImportance): sldImportance.setObjectName("sldImportance") @@ -34,4 +36,3 @@ class Ui_sldImportance(object): _translate = QtCore.QCoreApplication.translate sldImportance.setWindowTitle(_translate("sldImportance", "Form")) self.lbl.setText(_translate("sldImportance", "TextLabel")) - diff --git a/manuskript/ui/views/textEditView.py b/manuskript/ui/views/textEditView.py index 6c0e88e..b26a20c 100644 --- a/manuskript/ui/views/textEditView.py +++ b/manuskript/ui/views/textEditView.py @@ -1,11 +1,11 @@ #!/usr/bin/env python # --!-- coding: utf8 --!-- -import re +import re, textwrap from PyQt5.Qt import QApplication -from PyQt5.QtCore import QTimer, QModelIndex, Qt, QEvent, pyqtSignal, QRegExp, QLocale, QPersistentModelIndex +from PyQt5.QtCore import QTimer, QModelIndex, Qt, QEvent, pyqtSignal, QRegExp, QLocale, QPersistentModelIndex, QMutex from PyQt5.QtGui import QTextBlockFormat, QTextCharFormat, QFont, QColor, QIcon, QMouseEvent, QTextCursor -from PyQt5.QtWidgets import QWidget, QTextEdit, qApp, QAction, QMenu +from PyQt5.QtWidgets import QWidget, QTextEdit, qApp, QAction, QMenu, QToolTip from manuskript import settings from manuskript.enums import Outline, World, Character, Plot @@ -13,15 +13,19 @@ from manuskript import functions as F from manuskript.models import outlineModel, outlineItem from manuskript.ui.highlighters import BasicHighlighter from manuskript.ui import style as S +from manuskript.functions import Spellchecker +from manuskript.models.characterModel import Character, CharacterInfo -try: - import enchant -except ImportError: - enchant = None +import logging +LOGGER = logging.getLogger(__name__) + +# See implementation of QTextDocument::toPlainText() +PLAIN_TRANSLATION_TABLE = {0x2028: "\n", 0x2029: "\n", 0xfdd0: "\n", 0xfdd1: "\n"} class textEditView(QTextEdit): - def __init__(self, parent=None, index=None, html=None, spellcheck=True, + + def __init__(self, parent=None, index=None, html=None, spellcheck=None, highlighting=False, dict="", autoResize=False): QTextEdit.__init__(self, parent) self._column = Outline.text @@ -29,7 +33,7 @@ class textEditView(QTextEdit): self._indexes = None self._model = None self._placeholderText = self.placeholderText() - self._updating = False + self._updating = QMutex() self._item = None self._highlighting = highlighting self._textFormat = "text" @@ -39,6 +43,9 @@ class textEditView(QTextEdit): self._themeData = None self._highlighterClass = BasicHighlighter + if spellcheck == None: + spellcheck = settings.spellcheck + self.spellcheck = spellcheck self.currentDict = dict if dict else settings.dict self._defaultFontSize = qApp.font().pointSize() @@ -49,6 +56,8 @@ class textEditView(QTextEdit): self.highlightWord = "" self.highligtCS = False self._dict = None + self._tooltip = { 'depth' : 0, 'active' : 0 } + # self.document().contentsChanged.connect(self.submit, F.AUC) # Submit text changed only after 500ms without modifications @@ -56,13 +65,13 @@ class textEditView(QTextEdit): self.updateTimer.setInterval(500) self.updateTimer.setSingleShot(True) self.updateTimer.timeout.connect(self.submit) - # self.updateTimer.timeout.connect(lambda: print("Timeout")) + # self.updateTimer.timeout.connect(lambda: LOGGER.debug("Timeout.")) self.updateTimer.stop() self.document().contentsChanged.connect(self.updateTimer.start, F.AUC) - # self.document().contentsChanged.connect(lambda: print("Document changed")) + # self.document().contentsChanged.connect(lambda: LOGGER.debug("Document changed.")) - # self.document().contentsChanged.connect(lambda: print(self.objectName(), "Contents changed")) + # self.document().contentsChanged.connect(lambda: LOGGER.debug("Contents changed: %s", self.objectName())) self.setEnabled(False) @@ -74,29 +83,16 @@ class textEditView(QTextEdit): self.setReadOnly(True) # Spellchecking - if enchant and self.spellcheck: - try: - self._dict = enchant.Dict(self.currentDict if self.currentDict - else self.getDefaultLocale()) - except enchant.errors.DictNotFoundError: - self.spellcheck = False + if self.spellcheck: + self._dict = Spellchecker.getDictionary(self.currentDict) - else: + if not self._dict: self.spellcheck = False if self._highlighting and not self.highlighter: self.highlighter = self._highlighterClass(self) self.highlighter.setDefaultBlockFormat(self._defaultBlockFormat) - def getDefaultLocale(self): - default_locale = enchant.get_default_language() - if default_locale is None: - default_locale = QLocale.system().name() - if default_locale is None: - default_locale = enchant.list_dicts()[0][0] - - return default_locale - def setModel(self, model): self._model = model try: @@ -178,9 +174,9 @@ class textEditView(QTextEdit): def loadFontSettings(self): if self._fromTheme or \ - not self._index or \ - type(self._index.model()) != outlineModel or \ - self._column != Outline.text: + not self._index or \ + type(self._index.model()) != outlineModel or \ + self._column != Outline.text: return opt = settings.textEditor @@ -188,7 +184,7 @@ class textEditView(QTextEdit): f.fromString(opt["font"]) background = (opt["background"] if not opt["backgroundTransparent"] else "transparent") - foreground = opt["fontColor"] # if not opt["backgroundTransparent"] + foreground = opt["fontColor"] # if not opt["backgroundTransparent"] # else S.text # self.setFont(f) self.setStyleSheet("""QTextEdit{{ @@ -200,15 +196,16 @@ class textEditView(QTextEdit): {maxWidth} }} """.format( - bg=background, - foreground=foreground, - ff=f.family(), - fs="{}pt".format(str(f.pointSize())), - mTB = opt["marginsTB"], - mLR = opt["marginsLR"], - maxWidth = "max-width: {}px;".format(opt["maxWidth"]) if opt["maxWidth"] else "", - ) - ) + bg=background, + foreground=foreground, + ff=f.family(), + fs="{}pt".format(str(f.pointSize())), + mTB=opt["marginsTB"], + mLR=opt["marginsLR"], + maxWidth="max-width: {}px;".format( + opt["maxWidth"]) if opt["maxWidth"] else "", + ) + ) self._defaultFontSize = f.pointSize() # We set the parent background to the editor's background in case @@ -220,11 +217,11 @@ class textEditView(QTextEdit): QWidget#{name}{{ background: {bg}; }}""".format( - # We style by name, otherwise all inheriting widgets get the same - # colored background, for example context menu. - name=self.parent().objectName(), - bg=background, - )) + # We style by name, otherwise all inheriting widgets get the same + # colored background, for example context menu. + name=self.parent().objectName(), + bg=background, + )) cf = QTextCharFormat() # cf.setFont(f) @@ -252,15 +249,13 @@ class textEditView(QTextEdit): self.highlighter.setDefaultBlockFormat(self._defaultBlockFormat) def update(self, topLeft, bottomRight): - if self._updating: - return + update = False if self._index and self._index.isValid(): - if topLeft.parent() != self._index.parent(): return - # print("Model changed: ({}:{}), ({}:{}/{}), ({}:{}) for {} of {}".format( + # LOGGER.debug("Model changed: ({}:{}), ({}:{}/{}), ({}:{}) for {} of {}".format( # topLeft.row(), topLeft.column(), # self._index.row(), self._index.row(), self._column, # bottomRight.row(), bottomRight.column(), @@ -268,15 +263,15 @@ class textEditView(QTextEdit): if topLeft.row() <= self._index.row() <= bottomRight.row(): if topLeft.column() <= self._column <= bottomRight.column(): - self.updateText() + update = True elif self._indexes: - update = False for i in self._indexes: if topLeft.row() <= i.row() <= bottomRight.row(): update = True - if update: - self.updateText() + + if update: + self.updateText() def disconnectDocument(self): try: @@ -287,15 +282,21 @@ class textEditView(QTextEdit): def reconnectDocument(self): self.document().contentsChanged.connect(self.updateTimer.start, F.AUC) + def toIdealText(self): + """QTextDocument::toPlainText() replaces NBSP with spaces, which we don't want. + QTextDocument::toRawText() replaces nothing, but that leaves fancy paragraph and line separators that users would likely complain about. + This reimplements toPlainText(), except without the NBSP destruction.""" + return self.document().toRawText().translate(PLAIN_TRANSLATION_TABLE) + toPlainText = toIdealText + def updateText(self): - if self._updating: - return - # print("Updating", self.objectName()) - self._updating = True + self._updating.lock() + + # LOGGER.debug("Updating %s", self.objectName()) if self._index: self.disconnectDocument() - if self.toPlainText() != F.toString(self._index.data()): - # print(" Updating plaintext") + if self.toIdealText() != F.toString(self._index.data()): + # LOGGER.debug(" Updating plaintext") self.document().setPlainText(F.toString(self._index.data())) self.reconnectDocument() @@ -322,30 +323,29 @@ class textEditView(QTextEdit): self.setPlaceholderText(self.tr("Various")) self.reconnectDocument() - self._updating = False + + self._updating.unlock() def submit(self): self.updateTimer.stop() - if self._updating: - return - # print("Submitting", self.objectName()) + + self._updating.lock() + text = self.toIdealText() + self._updating.unlock() + + # LOGGER.debug("Submitting %s", self.objectName()) if self._index and self._index.isValid(): # item = self._index.internalPointer() - if self.toPlainText() != self._index.data(): - # print(" Submitting plain text") - self._updating = True - self._model.setData(QModelIndex(self._index), - self.toPlainText()) - self._updating = False + if text != self._index.data(): + # LOGGER.debug(" Submitting plain text") + self._model.setData(QModelIndex(self._index), text) elif self._indexes: - self._updating = True for i in self._indexes: item = i.internalPointer() - if self.toPlainText() != F.toString(item.data(self._column)): - print("Submitting many indexes") - self._model.setData(i, self.toPlainText()) - self._updating = False + if text != F.toString(item.data(self._column)): + LOGGER.debug("Submitting many indexes") + self._model.setData(i, text) def keyPressEvent(self, event): if event.key() == Qt.Key_V and event.modifiers() & Qt.ControlModifier: @@ -369,7 +369,7 @@ class textEditView(QTextEdit): opt = settings.textEditor docHeight = self.document().size().height() + 2 * opt["marginsTB"] if self.heightMin <= docHeight <= self.heightMax: - self.setMinimumHeight(docHeight) + self.setMinimumHeight(int(docHeight)) def setAutoResize(self, val): self._autoResize = val @@ -386,20 +386,18 @@ class textEditView(QTextEdit): def setDict(self, d): self.currentDict = d - if d and enchant.dict_exists(d): - self._dict = enchant.Dict(d) + if d: + self._dict = Spellchecker.getDictionary(d) if self.highlighter: self.highlighter.rehighlight() def toggleSpellcheck(self, v): self.spellcheck = v - if enchant and self.spellcheck and not self._dict: - if self.currentDict and enchant.dict_exists(self.currentDict): - self._dict = enchant.Dict(self.currentDict) - elif enchant.get_default_language() and enchant.dict_exists(enchant.get_default_language()): - self._dict = enchant.Dict(enchant.get_default_language()) - else: - self.spellcheck = False + if self.spellcheck and not self._dict: + self._dict = Spellchecker.getDictionary(self.currentDict) + + if not self._dict: + self.spellcheck = False if self.highlighter: self.highlighter.rehighlight() @@ -414,6 +412,49 @@ class textEditView(QTextEdit): Qt.LeftButton, Qt.LeftButton, Qt.NoModifier) QTextEdit.mousePressEvent(self, event) + def beginTooltipMoveEvent(self): + self._tooltip['depth'] += 1 + + def endTooltipMoveEvent(self): + self._tooltip['depth'] -= 1 + + def showTooltip(self, pos, text): + QToolTip.showText(pos, text) + self._tooltip['active'] = self._tooltip['depth'] + + def hideTooltip(self): + if self._tooltip['active'] == self._tooltip['depth']: + QToolTip.hideText() + + def mouseMoveEvent(self, event): + """ + When mouse moves, we show tooltip when appropriate. + """ + self.beginTooltipMoveEvent() + QTextEdit.mouseMoveEvent(self, event) + self.endTooltipMoveEvent() + + match = None + + # Check if the selected word has any suggestions for correction + if self.spellcheck and self._dict: + cursor = self.cursorForPosition(event.pos()) + + # Searches for correlating/overlapping matches + suggestions = self._dict.findSuggestions(self.toPlainText(), cursor.selectionStart(), cursor.selectionEnd()) + + if len(suggestions) > 0: + # I think it should focus on one type of error at a time. + match = suggestions[0] + + if match: + # Wrap the message into a fitting width + msg_lines = textwrap.wrap(match.msg, 48) + + self.showTooltip(event.globalPos(), "\n".join(msg_lines)) + else: + self.hideTooltip() + def wheelEvent(self, event): """ We catch wheelEvent if key modifier is CTRL to change font size. @@ -448,58 +489,198 @@ class textEditView(QTextEdit): QAction.__init__(self, *args) self.triggered.connect(lambda x: self.correct.emit( - str(self.text()))) + str(self.text()))) def contextMenuEvent(self, event): # Based on http://john.nachtimwald.com/2009/08/22/qplaintextedit-with-in-line-spell-check/ popup_menu = self.createStandardContextMenu() popup_menu.exec_(event.globalPos()) + def newCharacter(self): + text = self.sender().data() + LOGGER.debug(f'New character: {text}') + # switch to character page + mw = F.mainWindow() + mw.tabMain.setCurrentIndex(mw.TabPersos) + # add character + c = mw.mdlCharacter.addCharacter(name=text) + # switch to character + item = mw.lstCharacters.getItemByID(c.ID()) + mw.lstCharacters.setCurrentItem(item) + + def newPlotItem(self): + text = self.sender().data() + LOGGER.debug(f'New plot item: {text}') + # switch to plot page + mw = F.mainWindow() + mw.tabMain.setCurrentIndex(mw.TabPlots) + # add character + p, ID = mw.mdlPlots.addPlot(text) + # switch to character + plotIndex = mw.mdlPlots.getIndexFromID(ID.text()) + # segfaults for some reason + # mw.lstSubPlots.setCurrentIndex(plotIndex) + + def newWorldItem(self): + text = self.sender().data() + LOGGER.debug(f'New world item: {text}') + mw = F.mainWindow() + mw.tabMain.setCurrentIndex(mw.TabWorld) + item = mw.mdlWorld.addItem(title=text) + mw.treeWorld.setCurrentIndex( + mw.mdlWorld.indexFromItem(item)) + + + def appendContextMenuEntriesForWord(self, popup_menu, selectedWord): + # add "new" buttons at end + if selectedWord != None: + # new character + charAction = QAction(self.tr("&New Character"), popup_menu) + charAction.setIcon(F.themeIcon("characters")) + charAction.triggered.connect(self.newCharacter) + charAction.setData(selectedWord) + popup_menu.insertAction(None, charAction) + + # new plot item + plotAction = QAction(self.tr("&New Plot Item"), popup_menu) + plotAction.setIcon(F.themeIcon("plots")) + plotAction.triggered.connect(self.newPlotItem) + plotAction.setData(selectedWord) + popup_menu.insertAction(None, plotAction) + + # new world item + worldAction = QAction(self.tr("&New World Item"), popup_menu) + worldAction.setIcon(F.themeIcon("world")) + worldAction.triggered.connect(self.newWorldItem) + worldAction.setData(selectedWord) + popup_menu.insertAction(None, worldAction) + + return popup_menu + def createStandardContextMenu(self): popup_menu = QTextEdit.createStandardContextMenu(self) - if not self.spellcheck: - return popup_menu - - # Select the word under the cursor. - # But only if there is no selection (otherwise it's impossible to select more text to copy/cut) cursor = self.textCursor() - if not cursor.hasSelection(): - cursor.select(QTextCursor.WordUnderCursor) - self.setTextCursor(cursor) + selectedWord = cursor.selectedText() if cursor.hasSelection() else None + + if not self.spellcheck: + return self.appendContextMenuEntriesForWord(popup_menu, selectedWord) + + suggestions = [] + + # Check for any suggestions for corrections at the cursors position + if self._dict != None: + text = self.toPlainText() + + suggestions = self._dict.findSuggestions(text, cursor.selectionStart(), cursor.selectionEnd()) + + # Select the word under the cursor if necessary. + # But only if there is no selection (otherwise it's impossible to select more text to copy/cut) + if not cursor.hasSelection() and len(suggestions) == 0: + old_position = cursor.position() + + cursor.select(QTextCursor.WordUnderCursor) + self.setTextCursor(cursor) + + if cursor.hasSelection(): + selectedWord = cursor.selectedText() + + # Check if the selected word is misspelled and offer spelling + # suggestions if it is. + suggestions = self._dict.findSuggestions(text, cursor.selectionStart(), cursor.selectionEnd()) + + if len(suggestions) == 0: + cursor.clearSelection() + cursor.setPosition(old_position, QTextCursor.MoveAnchor) + self.setTextCursor(cursor) + + selectedWord = None + + popup_menu = self.appendContextMenuEntriesForWord(popup_menu, selectedWord) + + if len(suggestions) > 0 or selectedWord != None: + valid = len(suggestions) == 0 - # Check if the selected word is misspelled and offer spelling - # suggestions if it is. - if cursor.hasSelection(): - text = str(cursor.selectedText()) - valid = self._dict.check(text) - selectedWord = cursor.selectedText() if not valid: - spell_menu = QMenu(self.tr('Spelling Suggestions'), self) - spell_menu.setIcon(F.themeIcon("spelling")) - for word in self._dict.suggest(text): - action = self.SpellAction(word, spell_menu) - action.correct.connect(self.correctWord) - spell_menu.addAction(action) - # Only add the spelling suggests to the menu if there are - # suggestions. - if len(spell_menu.actions()) != 0: - popup_menu.insertSeparator(popup_menu.actions()[0]) + # I think it should focus on one type of error at a time. + match = suggestions[0] + + popup_menu.insertSeparator(popup_menu.actions()[0]) + + if match.locqualityissuetype == 'misspelling': + spell_menu = QMenu(self.tr('Spelling Suggestions'), self) + spell_menu.setIcon(F.themeIcon("spelling")) + + if (match.end > match.start and selectedWord == None): + # Select the actual area of the match + cursor = self.textCursor() + cursor.setPosition(match.start, QTextCursor.MoveAnchor); + cursor.setPosition(match.end, QTextCursor.KeepAnchor); + self.setTextCursor(cursor) + + selectedWord = cursor.selectedText() + + for word in match.replacements: + action = self.SpellAction(word, spell_menu) + action.correct.connect(self.correctWord) + spell_menu.addAction(action) + # Adds: add to dictionary addAction = QAction(self.tr("&Add to dictionary"), popup_menu) addAction.setIcon(QIcon.fromTheme("list-add")) addAction.triggered.connect(self.addWordToDict) addAction.setData(selectedWord) + popup_menu.insertAction(popup_menu.actions()[0], addAction) - # Adds: suggestions - popup_menu.insertMenu(popup_menu.actions()[0], spell_menu) - # popup_menu.insertSeparator(popup_menu.actions()[0]) + + # Only add the spelling suggests to the menu if there are + # suggestions. + if len(match.replacements) > 0: + # Adds: suggestions + popup_menu.insertMenu(popup_menu.actions()[0], spell_menu) + else: + correct_menu = None + correct_action = None + + if (len(match.replacements) > 0 and match.end > match.start): + # Select the actual area of the match + cursor = self.textCursor() + cursor.setPosition(match.start, QTextCursor.MoveAnchor); + cursor.setPosition(match.end, QTextCursor.KeepAnchor); + self.setTextCursor(cursor) + + if len(match.replacements) > 0: + correct_menu = QMenu(self.tr('&Correction Suggestions'), self) + correct_menu.setIcon(F.themeIcon("spelling")) + + for word in match.replacements: + action = self.SpellAction(word, correct_menu) + action.correct.connect(self.correctWord) + correct_menu.addAction(action) + + if correct_menu == None: + correct_action = QAction(self.tr('&Correction Suggestion'), popup_menu) + correct_action.setIcon(F.themeIcon("spelling")) + correct_action.setEnabled(False) + + # Wrap the message into a fitting width + msg_lines = textwrap.wrap(match.msg, 48) + + # Insert the lines of the message backwards + for i in range(0, len(msg_lines)): + popup_menu.insertSection(popup_menu.actions()[0], msg_lines[len(msg_lines) - (i + 1)]) + + if correct_menu != None: + popup_menu.insertMenu(popup_menu.actions()[0], correct_menu) + else: + popup_menu.insertAction(popup_menu.actions()[0], correct_action) # If word was added to custom dict, give the possibility to remove it - elif valid and self._dict.is_added(selectedWord): + elif self._dict.isCustomWord(selectedWord): popup_menu.insertSeparator(popup_menu.actions()[0]) # Adds: remove from dictionary - rmAction = QAction(self.tr("&Remove from custom dictionary"), popup_menu) + rmAction = QAction( + self.tr("&Remove from custom dictionary"), popup_menu) rmAction.setIcon(QIcon.fromTheme("list-remove")) rmAction.triggered.connect(self.rmWordFromDict) rmAction.setData(selectedWord) @@ -521,12 +702,12 @@ class textEditView(QTextEdit): def addWordToDict(self): word = self.sender().data() - self._dict.add(word) + self._dict.addWord(word) self.highlighter.rehighlight() def rmWordFromDict(self): word = self.sender().data() - self._dict.remove(word) + self._dict.removeWord(word) self.highlighter.rehighlight() ############################################################################### diff --git a/manuskript/ui/views/treeDelegates.py b/manuskript/ui/views/treeDelegates.py index fa59702..040b4f5 100644 --- a/manuskript/ui/views/treeDelegates.py +++ b/manuskript/ui/views/treeDelegates.py @@ -34,8 +34,8 @@ class treeTitleDelegate(QStyledItemDelegate): opt = QStyleOptionViewItem(option) self.initStyleOption(opt, index) - iconRect = style.subElementRect(style.SE_ItemViewItemDecoration, opt) - textRect = style.subElementRect(style.SE_ItemViewItemText, opt) + iconRect = style.subElementRect(style.SE_ItemViewItemDecoration, opt, None) + textRect = style.subElementRect(style.SE_ItemViewItemText, opt, None) # Background style.drawPrimitive(style.PE_PanelItemViewItem, opt, painter) @@ -111,6 +111,9 @@ class treeTitleDelegate(QStyledItemDelegate): elif settings.viewSettings["Tree"]["InfoFolder"] == "WC": extraText = item.wordCount() extraText = " ({})".format(extraText) + elif settings.viewSettings["Tree"]["InfoFolder"] == "CC": + extraText = item.charCount() + extraText = " ({})".format(extraText) elif settings.viewSettings["Tree"]["InfoFolder"] == "Progress": extraText = int(toFloat(item.data(Outline.goalPercentage)) * 100) if extraText: @@ -124,6 +127,9 @@ class treeTitleDelegate(QStyledItemDelegate): if settings.viewSettings["Tree"]["InfoText"] == "WC": extraText = item.wordCount() extraText = " ({})".format(extraText) + elif settings.viewSettings["Tree"]["InfoText"] == "CC": + extraText = item.charCount() + extraText = " ({})".format(extraText) elif settings.viewSettings["Tree"]["InfoText"] == "Progress": extraText = int(toFloat(item.data(Outline.goalPercentage)) * 100) if extraText: diff --git a/manuskript/ui/views/treeView.py b/manuskript/ui/views/treeView.py index 3fa7271..2655808 100644 --- a/manuskript/ui/views/treeView.py +++ b/manuskript/ui/views/treeView.py @@ -69,7 +69,7 @@ class treeView(QTreeView, dndView, outlineBasics): return menu def expandCurrentIndex(self, index=None): - if index is None or type(index) == bool: + if index == None or type(index) == bool: index = self._indexesToOpen[0] # self.currentIndex() self.expand(index) @@ -78,7 +78,7 @@ class treeView(QTreeView, dndView, outlineBasics): self.expandCurrentIndex(index=idx) def collapseCurrentIndex(self, index=None): - if index is None or type(index) == bool: + if index == None or type(index) == bool: index = self._indexesToOpen[0] # self.currentIndex() self.collapse(index) diff --git a/manuskript/ui/views/webView.py b/manuskript/ui/views/webView.py index 4d6462c..23e09d1 100644 --- a/manuskript/ui/views/webView.py +++ b/manuskript/ui/views/webView.py @@ -22,16 +22,13 @@ else: if features['qtwebkit']: from PyQt5.QtWebKitWidgets import QWebView - print("Debug: Web rendering engine used: QWebView") webEngine = "QtWebKit" webView = QWebView elif features['qtwebengine']: from PyQt5 import QtWebEngineWidgets - print("Debug: Web rendering engine used: QWebEngineView") webEngine = "QtWebEngine" webView = QtWebEngineWidgets.QWebEngineView else: from PyQt5.QtWidgets import QTextEdit - print("Debug: Web rendering engine used: QTextEdit") webEngine = "QTextEdit" webView = QTextEdit diff --git a/manuskript/ui/welcome.py b/manuskript/ui/welcome.py index b022ebc..fce923b 100644 --- a/manuskript/ui/welcome.py +++ b/manuskript/ui/welcome.py @@ -2,7 +2,7 @@ # --!-- coding: utf8 --!-- import locale -import imp +import importlib import os from PyQt5.QtCore import QSettings, QRegExp, Qt, QDir @@ -21,6 +21,9 @@ from manuskript.models.worldModel import worldModel from manuskript.ui.welcome_ui import Ui_welcome from manuskript.ui import style as S +import logging +LOGGER = logging.getLogger(__name__) + try: locale.setlocale(locale.LC_ALL, '') except: @@ -57,8 +60,7 @@ class welcome(QWidget, Ui_welcome): sttgs = QSettings() lastDirectory = sttgs.value("lastAccessedDirectory", defaultValue=".", type=str) if lastDirectory != '.': - print(qApp.translate("lastAccessedDirectoryInfo", "Last accessed directory \"{}\" loaded.").format( - lastDirectory)) + LOGGER.info("Last accessed directory \"{}\" loaded.".format(lastDirectory)) return lastDirectory def setLastAccessedDirectory(self, dir): @@ -286,18 +288,19 @@ class welcome(QWidget, Ui_welcome): k = 0 hasWC = False - for d in self.template[1]: + for templateIndex, d in enumerate(self.template[1]): spin = QSpinBox(self) spin.setRange(0, 999999) spin.setValue(d[0]) # Storing the level of the template in that spinbox, so we can use # it to update the template when valueChanged on that spinbox # (we do that in self.updateWordCount for convenience). - spin.setProperty("templateIndex", self.template[1].index(d)) + spin.setProperty("templateIndex", templateIndex) spin.valueChanged.connect(self.updateWordCount) - if d[1] != None: txt = QLineEdit(self) + txt.setProperty("templateIndex", templateIndex) + txt.textEdited.connect(self.updateWordCount) txt.setText(d[1]) else: @@ -360,12 +363,21 @@ class welcome(QWidget, Ui_welcome): Qt.FindChildrenRecursively): total = total * s.value() - # Update self.template to reflect the changed values + # Update self.template to reflect the changed count values templateIndex = s.property("templateIndex") self.template[1][templateIndex] = ( s.value(), self.template[1][templateIndex][1]) + for t in self.findChildren(QLineEdit, QRegExp(".*"), + Qt.FindChildrenRecursively): + # Update self.template to reflect the changed name values + templateIndex = t.property("templateIndex") + if templateIndex != None : + self.template[1][templateIndex] = ( + self.template[1][templateIndex][0], + t.text()) + if total == 1: total = 0 @@ -412,10 +424,12 @@ class welcome(QWidget, Ui_welcome): self.tree.expandAll() def loadDefaultDatas(self): + """Initialize a basic Manuskript project.""" # Empty settings - imp.reload(settings) + importlib.reload(settings) settings.initDefaultValues() + self.mw.loadEmptyDatas() if self.template: t = [i for i in self._templates if i[0] == self.template[0]] @@ -423,20 +437,10 @@ class welcome(QWidget, Ui_welcome): settings.viewMode = "simple" # Tasks - self.mw.mdlFlatData = QStandardItemModel(2, 8, self.mw) - - # Persos - # self.mw.mdlPersos = QStandardItemModel(0, 0, self.mw) - self.mw.mdlCharacter = characterModel(self.mw) - # self.mdlPersosProxy = None # persosProxyModel() # None - # self.mw.mdlPersosProxy = persosProxyModel(self.mw) - - # self.mw.mdlPersosInfos = QStandardItemModel(1, 0, self.mw) - # self.mw.mdlPersosInfos.insertColumn(0, [QStandardItem("ID")]) - # self.mw.mdlPersosInfos.setHorizontalHeaderLabels(["Description"]) + self.mw.mdlFlatData.setRowCount(2) # data from: infos.txt, summary.txt + self.mw.mdlFlatData.setColumnCount(8) # version_1.py: len(infos.txt) == 8 # Labels - self.mw.mdlLabels = QStandardItemModel(self.mw) for color, text in [ (Qt.transparent, ""), (Qt.yellow, self.tr("Idea")), @@ -448,7 +452,6 @@ class welcome(QWidget, Ui_welcome): self.mw.mdlLabels.appendRow(QStandardItem(iconFromColor(color), text)) # Status - self.mw.mdlStatus = QStandardItemModel(self.mw) for text in [ "", self.tr("TODO"), @@ -458,14 +461,9 @@ class welcome(QWidget, Ui_welcome): ]: self.mw.mdlStatus.appendRow(QStandardItem(text)) - # Plot - self.mw.mdlPlots = plotModel(self.mw) + # Plot (nothing special needed) # Outline - self.mw.mdlOutline = outlineModel(self.mw) - - # World - self.mw.mdlWorld = worldModel(self.mw) root = self.mw.mdlOutline.rootItem _type = "md" @@ -499,3 +497,5 @@ class welcome(QWidget, Ui_welcome): if self.template and self.template[1]: addElement(root, self.template[1]) + + # World (nothing special needed) diff --git a/manuskript/version.py b/manuskript/version.py index fcc4392..b333811 100644 --- a/manuskript/version.py +++ b/manuskript/version.py @@ -3,7 +3,7 @@ # Single source the package version # https://packaging.python.org/guides/single-sourcing-package-version/ -__version__ = "0.8.0" +__version__ = "0.14.0" def getVersion(): return __version__ diff --git a/package/build_for_windows.sh b/package/build_for_windows.sh new file mode 100755 index 0000000..2639f06 --- /dev/null +++ b/package/build_for_windows.sh @@ -0,0 +1,123 @@ +#!/bin/sh +EXEC_DIR=$(pwd) + +DIR=$(mktemp -d /tmp/manuskript-windows.XXXXXX) +PREFIX=$DIR/.wine + +BUILD_ARCH="win32" +PY_VERSION="3.8.10" + +PY_NAME="python-$PY_VERSION" + +if [ "$BUILD_ARCH" = "win64" ]; then + PY_NAME="$PY_NAME-amd64" +fi + +# Install Python: +PY_DOWNLOAD="https://www.python.org/ftp/python/$PY_VERSION/$PY_NAME.exe" +PY_SETUP=$(echo $PY_DOWNLOAD | tr '/' ' ' | awk '{ print $(NF) }') + +if [ ! -e $PY_SETUP ]; then + wget $PY_DOWNLOAD +fi + +WINEPREFIX=$PREFIX WINEARCH=$BUILD_ARCH wine $PY_SETUP /quiet InstallAllUsers=1 PrependPath=1 Include_test=0 + +# Install Pandoc: +PAN_DOWNLOAD="https://github.com/jgm/pandoc/releases/download/2.16.1/pandoc-2.16.1-windows-x86_64.msi" +PAN_SETUP=$(echo $PAN_DOWNLOAD | tr '/' ' ' | awk '{ print $(NF) }') + +if [ ! -e $PAN_SETUP ]; then + wget $PAN_DOWNLOAD +fi + +WINEPREFIX=$PREFIX WINEARCH=$BUILD_ARCH wine $PAN_SETUP /qn /norestart + +PY_DIR="Python$(echo $PY_VERSION | sed -e s/\\./\ /g - | awk '{ print $1$2 }')" + +if [ "$BUILD_ARCH" = "win32" ]; then + PY_DIR="$PY_DIR-32" +fi + +# Install most dependencies with pip: +cd $PREFIX/drive_c/Program\ Files/$PY_DIR/ + +pip_install() { + WINEPREFIX=$PREFIX WINEARCH=$BUILD_ARCH wine python.exe Scripts/pip.exe install $@ +} + +# Upgrade pip to mitigate problems: +pip_install --upgrade pip + +# Install required dependencies: + +# Version 4.4 does not cause the issue with lxml (potential fallback) +pip_install https://github.com/pyinstaller/pyinstaller/archive/develop.zip +#pip_install pyinstaller==4.4 + +pip_install lxml +pip_install PyQt5 + +# Install optional dependencies: +pip_install pyenchant +pip_install pyspellchecker +pip_install symspellpy +pip_install language_tool_python +pip_install markdown + +# Clone the repository from Github: +REPOSITORY="https://github.com/olivierkes/manuskript.git" + +cd $DIR +git clone $REPOSITORY +cd manuskript + +PKG_VERSION=$(grep -E "__version__.*\".*\"" "manuskript/version.py" | cut -d\" -f2) + +# Run PyInstaller to create the build: +WINEPREFIX=$PREFIX WINEARCH=$BUILD_ARCH wine pyinstaller manuskript.spec +cat build/manuskript/warn-manuskript.txt + +cd dist/manuskript + +# Remove this library (causing weird bluetooth problems): +### comment: We don't need bluetooth anyway... ^^' +rm Qt5Bluetooth.dll + +# Remove this library (causing a crash on Windows 7): +rm ucrtbase.dll + +# Remove unnecessary libraries: +rm api-ms-win-* + +# Test Manuskript: +### comment: Seems to work fine... +WINEPREFIX=$PREFIX WINEARCH=$BUILD_ARCH wine manuskript.exe & +WINE_TEST_PID=$! + +sleep 5 +cd .. + +tmp_cleanup() { + # Cleanup everything: + ### comment: removing the local git repository + ### requires write permissions... + chmod +w -R $DIR/manuskript + rm -r $DIR +} + +if [ $(ps $WINE_TEST_PID | grep manuskript.exe | wc -l) -gt 0 ]; then + kill $WINE_TEST_PID +else + echo "ERROR: Package has crashed to an critical error!" + tmp_cleanup + exit +fi + +ZIP_NAME=manuskript-$PKG_VERSION-$BUILD_ARCH.zip + +# Package everything together: +zip -r $ZIP_NAME manuskript +mv $ZIP_NAME $EXEC_DIR + +tmp_cleanup diff --git a/package/build_osx.sh b/package/build_osx.sh index 299dbcd..72a4275 100755 --- a/package/build_osx.sh +++ b/package/build_osx.sh @@ -4,7 +4,16 @@ if [ z"$FILENAME" = "z" ]; then echo "Error: Environment variable FILENAME is not set" exit 1 fi -pyinstaller manuskript.spec --clean +filename="${FILENAME%.*}".dmg +export manuskript_version=$TRAVIS_BRANCH +package/osx/rebuild_mac_icon.sh +pyinstaller manuskript.spec --clean --noconfirm +# Fix signing the app - know issue with Qt5 +python3 package/osx/fix_app_qt_folder_names_for_codesign.py dist/manuskript.app +codesign -s - --force --all-architectures --timestamp --deep dist/manuskript.app +# Create the installer +dmgbuild -s package/osx/dmg-settings.py "manuskript" dist/${filename} cd dist && zip $FILENAME -r manuskript && cd .. ls dist cp dist/$FILENAME dist/manuskript-osx-develop.zip +cp dist/$filename dist/manuskript-osx-develop.dmg diff --git a/package/create_deb.sh b/package/create_deb.sh index 444aea4..546e03b 100755 --- a/package/create_deb.sh +++ b/package/create_deb.sh @@ -30,13 +30,19 @@ echo " [✓]" # wget https://github.com/olivierkes/manuskript/archive/$AppVersion.tar.gz # tar -xvf $AppVersion.tar.gz # rm $AppVersion.tar.gz -# mv manuskript-0.5.0 manuskript +# mv manuskript-0.14.0 manuskript # popd # Using the current direction as source echo -n "Copying manuskript content" -rsync -a --exclude=.git --include="*.msk" --exclude-from="$Root/.gitignore" \ +rsync -a --exclude=.git \ + --exclude=dist \ + --exclude=rpmbuild \ + --exclude=snap \ + --exclude=package \ + --include="*.msk" \ + --exclude-from="$Root/.gitignore" \ "$ScriptPath/../" "$Dest/usr/share/manuskript" cp "$ScriptPath/create_deb/manuskript" "$Dest/usr/bin/manuskript" cp "$ScriptPath/create_deb/manuskript.desktop" \ @@ -55,8 +61,9 @@ echo " [✓]" echo "Your root password might now be asked to finish setting permissions:" sudo chown root:root -R "$Dest" +# Use xz compression to make sure Debian can handle it! echo "Creating the package…" -dpkg -b "$Dest" +dpkg-deb -b -Zxz "$Dest" echo -n "Removing build folder" sudo rm -r "$Dest" diff --git a/package/create_deb/control b/package/create_deb/control index a8e3296..8e1853d 100644 --- a/package/create_deb/control +++ b/package/create_deb/control @@ -1,6 +1,6 @@ Package: manuskript Version: {PkgVersion} -Maintainer: Curtis Gedak +Maintainer: Tobias Frisch Description: Manuskript open source tool for writers. Manuskript is an open source tool for writers. It provides a rich environment to help writers create @@ -13,7 +13,7 @@ Architecture: all Origin: Ubuntu 14.04 Bugs: https://github.com/olivierkes/manuskript/issues Homepage: http://www.theologeek.ch/manuskript/ -Source: https://github.com/olivierkes/manuskript/archive/0.5.0.tar.gz +Source: https://github.com/olivierkes/manuskript/archive/0.14.0.tar.gz Depends: python3, python3-pyqt5, python3-pyqt5.qtwebkit, libqt5svg5, python3-lxml, zlib1g, python3-enchant, python3-markdown, pandoc Suggests: texlive-latex-recommended diff --git a/package/create_rpm.sh b/package/create_rpm.sh index 47300b5..9fc31a5 100755 --- a/package/create_rpm.sh +++ b/package/create_rpm.sh @@ -55,9 +55,14 @@ echo "### Creating tarball folder structure" echo_do eval "mkdir -p $Dest/$AppName-$AppVersion/{usr/share/applications,usr/bin/}" echo "### Copying manuskript content" -echo_do eval "rsync -a --exclude=.git --include='*.msk' \ +echo_do eval "rsync -a --exclude=.git \ + --include='*.msk' \ + --exclude=.github \ --exclude-from='$Root/.gitignore' \ --exclude=rpmbuild \ + --exclude=dist \ + --exclude=snap \ + --exclude=package \ --exclude={.codeclimate.yml,.gitignore,.travis.yml} \ $ScriptPath/../ $Dest/$AppName-$AppVersion/usr/share/manuskript" # Note: Files manuskript and manuskript.desktop are same as in Debian diff --git a/package/flatpak/.gitignore b/package/flatpak/.gitignore new file mode 100644 index 0000000..8423336 --- /dev/null +++ b/package/flatpak/.gitignore @@ -0,0 +1,2 @@ +.flatpak-builder +build-dir diff --git a/package/flatpak/manuskript.py b/package/flatpak/manuskript.py new file mode 100644 index 0000000..7e6d122 --- /dev/null +++ b/package/flatpak/manuskript.py @@ -0,0 +1,12 @@ +#! /usr/bin/env python3 + +import sys +sys.path.insert(1, "/app/lib/manuskript") +print(sys.version) + +# for debugging only +import PyQt5.QtWebEngineWidgets as webengine +print(webengine) + +from manuskript import main +main.run() diff --git a/package/osx/dmg-settings.py b/package/osx/dmg-settings.py new file mode 100644 index 0000000..108e668 --- /dev/null +++ b/package/osx/dmg-settings.py @@ -0,0 +1,184 @@ +import os.path + +import plistlib + +# +# Example settings file for dmgbuild +# + +# Use like this: dmgbuild -s settings.py "Test Volume" test.dmg + +# You can actually use this file by doing e.g. +# +# dmgbuild -s settings.py -D app=/path/to/My.app "My Application" MyApp.dmg + +# .. Useful stuff .............................................................. + +application = defines.get('app', 'dist/manuskript.app') # noqa: F821 +appname = os.path.basename(application) + + +def icon_from_app(app_path): + plist_path = os.path.join(app_path, 'Contents', 'Info.plist') + with open(plist_path, 'rb') as f: + plist = plistlib.load(f) + icon_name = plist['CFBundleIconFile'] + icon_root, icon_ext = os.path.splitext(icon_name) + if not icon_ext: + icon_ext = '.icns' + icon_name = icon_root + icon_ext + return os.path.join(app_path, 'Contents', 'Resources', icon_name) + + +# .. Basics .................................................................... + +# Uncomment to override the output filename +# filename = 'test.dmg' + +# Uncomment to override the output volume name +# volume_name = 'Test' + +# Volume format (see hdiutil create -help) +format = defines.get('format', 'UDBZ') # noqa: F821 + +# Compression level (if relevant) +# compression_level = 9 + +# Volume size +size = defines.get('size', None) # noqa: F821 + +# Files to include +files = [application] + +# Symlinks to create +symlinks = {'Applications': '/Applications'} + +# Files to hide +# hide = [ 'Secret.data' ] + +# Files to hide the extension of +# hide_extension = [ 'README.rst' ] + +# Volume icon +# +# You can either define icon, in which case that icon file will be copied to the +# image, *or* you can define badge_icon, in which case the icon file you specify +# will be used to badge the system's Removable Disk icon. Badge icons require +# pyobjc-framework-Quartz. +# +# icon = '/path/to/icon.icns' +badge_icon = icon_from_app(application) + +# Where to put the icons +icon_locations = { + appname: (140, 120), + 'Applications': (500, 120) +} + +# .. Window configuration ...................................................... + +# Background +# +# This is a STRING containing any of the following: +# +# #3344ff - web-style RGB color +# #34f - web-style RGB color, short form (#34f == #3344ff) +# rgb(1,0,0) - RGB color, each value is between 0 and 1 +# hsl(120,1,.5) - HSL (hue saturation lightness) color +# hwb(300,0,0) - HWB (hue whiteness blackness) color +# cmyk(0,1,0,0) - CMYK color +# goldenrod - X11/SVG named color +# builtin-arrow - A simple built-in background with a blue arrow +# /foo/bar/baz.png - The path to an image file +# +# The hue component in hsl() and hwb() may include a unit; it defaults to +# degrees ('deg'), but also supports radians ('rad') and gradians ('grad' +# or 'gon'). +# +# Other color components may be expressed either in the range 0 to 1, or +# as percentages (e.g. 60% is equivalent to 0.6). +background = 'builtin-arrow' + +show_status_bar = False +show_tab_view = False +show_toolbar = False +show_pathbar = False +show_sidebar = False +sidebar_width = 180 + +# Window position in ((x, y), (w, h)) format +window_rect = ((100, 100), (640, 280)) + +# Select the default view; must be one of +# +# 'icon-view' +# 'list-view' +# 'column-view' +# 'coverflow' +# +default_view = 'icon-view' + +# General view configuration +show_icon_preview = False + +# Set these to True to force inclusion of icon/list view settings (otherwise +# we only include settings for the default view) +include_icon_view_settings = 'auto' +include_list_view_settings = 'auto' + +# .. Icon view configuration ................................................... + +arrange_by = None +grid_offset = (0, 0) +grid_spacing = 100 +scroll_position = (0, 0) +label_pos = 'bottom' # or 'right' +text_size = 16 +icon_size = 128 + +# .. List view configuration ................................................... + +# Column names are as follows: +# +# name +# date-modified +# date-created +# date-added +# date-last-opened +# size +# kind +# label +# version +# comments +# +list_icon_size = 16 +list_text_size = 12 +list_scroll_position = (0, 0) +list_sort_by = 'name' +list_use_relative_dates = True +list_calculate_all_sizes = False, +list_columns = ('name', 'date-modified', 'size', 'kind', 'date-added') +list_column_widths = { + 'name': 300, + 'date-modified': 181, + 'date-created': 181, + 'date-added': 181, + 'date-last-opened': 181, + 'size': 97, + 'kind': 115, + 'label': 100, + 'version': 75, + 'comments': 300, + } +list_column_sort_directions = { + 'name': 'ascending', + 'date-modified': 'descending', + 'date-created': 'descending', + 'date-added': 'descending', + 'date-last-opened': 'descending', + 'size': 'descending', + 'kind': 'ascending', + 'label': 'ascending', + 'version': 'ascending', + 'comments': 'ascending', + } \ No newline at end of file diff --git a/package/osx/fix_app_qt_folder_names_for_codesign.py b/package/osx/fix_app_qt_folder_names_for_codesign.py new file mode 100644 index 0000000..14c0ba8 --- /dev/null +++ b/package/osx/fix_app_qt_folder_names_for_codesign.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +import os +import shutil +import sys +from pathlib import Path +from typing import Generator, List, Optional + +from macholib.MachO import MachO + +# Source: https://github.com/pyinstaller/pyinstaller/wiki/Recipe-OSX-Code-Signing-Qt + +def create_symlink(folder: Path) -> None: + """Create the appropriate symlink in the MacOS folder + pointing to the Resources folder. + """ + sibbling = Path(str(folder).replace("MacOS", "")) + + # PyQt5/Qt/qml/QtQml/Models.2 + root = str(sibbling).partition("Contents")[2].lstrip("/") + # ../../../../ + backward = "../" * (root.count("/") + 1) + # ../../../../Resources/PyQt5/Qt/qml/QtQml/Models.2 + good_path = f"{backward}Resources/{root}" + + folder.symlink_to(good_path) + + +def fix_dll(dll: Path) -> None: + """Fix the DLL lookup paths to use relative ones for Qt dependencies. + Inspiration: PyInstaller/depend/dylib.py:mac_set_relative_dylib_deps() + Currently one header is pointing to (we are in the Resources folder): + @loader_path/../../../../QtCore (it is referencing to the old MacOS folder) + It will be converted to: + @loader_path/../../../../../../MacOS/QtCore + """ + + def match_func(pth: str) -> Optional[str]: + """Callback function for MachO.rewriteLoadCommands() that is + called on every lookup path setted in the DLL headers. + By returning None for system libraries, it changes nothing. + Else we return a relative path pointing to the good file + in the MacOS folder. + """ + basename = os.path.basename(pth) + if not basename.startswith("Qt"): + return None + return f"@loader_path{good_path}/{basename}" + + # Resources/PyQt5/Qt/qml/QtQuick/Controls.2/Fusion + root = str(dll.parent).partition("Contents")[2][1:] + # /../../../../../../.. + backward = "/.." * (root.count("/") + 1) + # /../../../../../../../MacOS + good_path = f"{backward}/MacOS" + + # Rewrite Mach headers with corrected @loader_path + dll = MachO(dll) + dll.rewriteLoadCommands(match_func) + with open(dll.filename, "rb+") as f: + for header in dll.headers: + f.seek(0) + dll.write(f) + f.seek(0, 2) + f.flush() + + +def find_problematic_folders(folder: Path) -> Generator[Path, None, None]: + """Recursively yields problematic folders (containing a dot in their name).""" + for path in folder.iterdir(): + if not path.is_dir() or path.is_symlink(): + # Skip simlinks as they are allowed (even with a dot) + continue + if "." in path.name: + yield path + else: + yield from find_problematic_folders(path) + + +def move_contents_to_resources(folder: Path) -> Generator[Path, None, None]: + """Recursively move any non symlink file from a problematic folder + to the sibbling one in Resources. + """ + for path in folder.iterdir(): + if path.is_symlink(): + continue + if path.name == "qml": + yield from move_contents_to_resources(path) + else: + sibbling = Path(str(path).replace("MacOS", "Resources")) + sibbling.parent.mkdir(parents=True, exist_ok=True) + shutil.move(path, sibbling) + yield sibbling + + +def main(args: List[str]) -> int: + """ + Fix the application to allow codesign (NXDRIVE-1301). + Take one or more .app as arguments: "Nuxeo Drive.app". + To overall process will: + - move problematic folders from MacOS to Resources + - fix the DLLs lookup paths + - create the appropriate symbolic link + """ + for app in args: + name = os.path.basename(app) + print(f">>> [{name}] Fixing Qt folder names") + path = Path(app) / "Contents" / "MacOS" + for folder in find_problematic_folders(path): + for file in move_contents_to_resources(folder): + try: + fix_dll(file) + except (ValueError, IsADirectoryError): + continue + shutil.rmtree(folder) + create_symlink(folder) + print(f" !! Fixed {folder}") + print(f">>> [{name}] Application fixed.") + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) \ No newline at end of file diff --git a/package/osx/rebuild_mac_icon.sh b/package/osx/rebuild_mac_icon.sh new file mode 100755 index 0000000..9d77a3b --- /dev/null +++ b/package/osx/rebuild_mac_icon.sh @@ -0,0 +1,22 @@ +#!/bin/sh + + +# Build macOS specific icon set + +ICON_FOLDER=icons/Manuskript +FULLSIZE_ICON=$ICON_FOLDER/icon-512px.png +TMP_ICONSET_FOLDER=$ICON_FOLDER/Manuskript.iconset +TARGET_ICONSET=$ICON_FOLDER/Manuskript.icns +mkdir $TMP_ICONSET_FOLDER +sips -z 16 16 $FULLSIZE_ICON --out $TMP_ICONSET_FOLDER/icon_16x16.png +sips -z 32 32 $FULLSIZE_ICON --out $TMP_ICONSET_FOLDER/icon_16x16@2x.png +sips -z 32 32 $FULLSIZE_ICON --out $TMP_ICONSET_FOLDER/icon_32x32.png +sips -z 64 64 $FULLSIZE_ICON --out $TMP_ICONSET_FOLDER/icon_32x32@2x.png +sips -z 128 128 $FULLSIZE_ICON --out $TMP_ICONSET_FOLDER/icon_128x128.png +sips -z 256 256 $FULLSIZE_ICON --out $TMP_ICONSET_FOLDER/icon_128x128@2x.png +sips -z 256 256 $FULLSIZE_ICON --out $TMP_ICONSET_FOLDER/icon_256x256.png +sips -z 512 512 $FULLSIZE_ICON --out $TMP_ICONSET_FOLDER/icon_256x256@2x.png +sips -z 512 512 $FULLSIZE_ICON --out $TMP_ICONSET_FOLDER/icon_512x512.png +sips -z 1024 1024 $FULLSIZE_ICON --out $TMP_ICONSET_FOLDER/icon_512x512@2x.png +iconutil -c icns --output $TARGET_ICONSET $TMP_ICONSET_FOLDER +rm -R $TMP_ICONSET_FOLDER \ No newline at end of file diff --git a/package/prepare_linux.sh b/package/prepare_linux.sh index a1ce26f..be3e648 100755 --- a/package/prepare_linux.sh +++ b/package/prepare_linux.sh @@ -5,7 +5,7 @@ sudo apt-get -qq install python3-pip python3-dev \ build-essential qt5-default libxml2-dev libxslt1-dev \ mesa-utils libgl1-mesa-glx libgl1-mesa-dev -pyenv local 3.6.3 -python --version -easy_install pip -pip install pyqt5==5.9 lxml pytest pytest-faulthandler +pyenv local 3.6.7 +python3 --version +sudo pip3 install --upgrade pip setuptools wheel +pip3 install pyqt5==5.9 lxml pytest pytest-faulthandler diff --git a/package/prepare_osx.sh b/package/prepare_osx.sh index 8f937a1..ae642fc 100755 --- a/package/prepare_osx.sh +++ b/package/prepare_osx.sh @@ -1,12 +1,18 @@ #!/bin/bash set -ev # display each line executed along with output -brew update + +# seriously the CI needs to stop testing 99% of the time if homebrew has updates or not +export HOMEBREW_NO_AUTO_UPDATE=1 # (please let it go, homebrew!) + +# brew update # (safe the CI some time) + # Upgrade to python 3.x -brew upgrade python +# brew upgrade python # (should be fine) + brew install enchant brew postinstall python # this installs pip sudo -H pip3 install --upgrade pip setuptools wheel -pip3 install pyinstaller PyQt5 lxml pyenchant pytest pytest-faulthandler +pip3 install pyinstaller PyQt5 lxml pyenchant pytest pytest-faulthandler pillow dmgbuild brew install hunspell # Fooling PyEnchant as described in the wiki. # https://github.com/olivierkes/manuskript/wiki/Package-manuskript-for-OS-X diff --git a/resources/ch.theologeek.Manuskript.appdata.xml b/resources/ch.theologeek.Manuskript.appdata.xml new file mode 100644 index 0000000..40c0e8c --- /dev/null +++ b/resources/ch.theologeek.Manuskript.appdata.xml @@ -0,0 +1,63 @@ + + + + diff --git a/resources/ch.theologeek.Manuskript.desktop b/resources/ch.theologeek.Manuskript.desktop new file mode 100644 index 0000000..afb8347 --- /dev/null +++ b/resources/ch.theologeek.Manuskript.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Terminal=false +Name=Manuskript +TryExec=manuskript +Exec=manuskript +Icon=ch.theologeek.Manuskript +Categories=Office;WordProcessor; +Keywords=manuskript;office;write;edit;novel;text;msk +Comment=An open source tool for writers diff --git a/snap/gui/manuskript.desktop b/snap/gui/manuskript.desktop new file mode 100644 index 0000000..776d737 --- /dev/null +++ b/snap/gui/manuskript.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Name=Manuskript +Comment=An open source tool for writers +Keywords=manuskript;office;write;edit;novel;text;msk +Exec=manuskript +Terminal=false +Type=Application +Icon=${SNAP}/meta/gui/manuskript.svg +Categories=Office;WordProcessor; diff --git a/snap/gui/manuskript.svg b/snap/gui/manuskript.svg new file mode 100644 index 0000000..596b407 --- /dev/null +++ b/snap/gui/manuskript.svg @@ -0,0 +1,109 @@ + + + + diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml new file mode 100644 index 0000000..100ad22 --- /dev/null +++ b/snap/snapcraft.yaml @@ -0,0 +1,128 @@ +name: manuskript +title: Manuskript +summary: Manuskript is an open-source tool for writers. +description: | + Manuskript provides a rich environment to help writers create their first draft and then further refine and edit their masterpiece. + With Manuskript you can: + * Grow your premise from one sentence, to a paragraph, to a full summary + * Create characters + * Conceive plots + * Construct outlines (Outline mode and/or Index cards) + * Write with focus (Distraction free mode) + * Build worlds + * Track items + * Edit and re-organize chapters and scenes + * View Story line + * Compose with fiction or non-fiction templates and writing modes + * Export to HTML, ePub, OpenDocument, DocX, PDF, and more + * Additionally Manuskript can help in many more ways with a spell checker, markdown highlighter, frequency analyzer, and automatic save in open and plain text file format. +adopt-info: manuskript +grade: stable +confinement: strict +base: core18 +icon: icons/Manuskript/manuskript.svg +layout: + /usr/share/pandoc/data/templates: + bind: $SNAP/usr/share/pandoc/data/templates + +apps: + manuskript: + command: bin/desktop-launch $SNAP/bin/manuskript + environment: + # Fallback to XWayland if running in a Wayland session. + DISABLE_WAYLAND: 1 + plugs: + - desktop + - desktop-legacy + - unity7 + - wayland + - x11 + - opengl + - home + - network-manager # (Seems to be necessary on older Ubuntu to open the window) + +parts: + desktop-qt5: + build-packages: + - build-essential + - qtbase5-dev + - dpkg-dev + make-parameters: + - FLAVOR=qt5 + plugin: make + source: https://github.com/ubuntu/snapcraft-desktop-helpers.git + source-subdir: qt + stage-packages: + - libxkbcommon0 + - ttf-ubuntu-font-family + - dmz-cursor-theme + - light-themes + - adwaita-icon-theme + - gnome-themes-standard + - shared-mime-info + - libqt5gui5 + - libgdk-pixbuf2.0-0 + - libqt5svg5 + - try: + - appmenu-qt5 + - locales-all + - xdg-user-dirs + - fcitx-frontend-qt5 + - libxcursor1 + - libgtk2.0-0 + + pip-fix: + plugin: nil + stage-packages: + - python3-distutils + - python3-setuptools + + pyspellchecker: + after: [pip-fix] + plugin: python + python-version: python3 + source: https://github.com/barrust/pyspellchecker.git + + manuskript: + after: [desktop-qt5,pyspellchecker] + plugin: dump + source: https://github.com/olivierkes/manuskript.git + source-type: git + source-branch: 'master' + build-packages: + - python3 + - python3-pyqt5 + - python3-pyqt5.qtwebkit + - libqt5svg5 + - python3-lxml + - zlib1g + - python3-enchant + - python3-markdown + - pandoc + - texlive-latex-recommended + - texlive-fonts-recommended + - texlive-latex-extra + - texlive-luatex + - texlive-xetex + override-pull: | + snapcraftctl pull + + version="$(grep -E '__version__.*\".*\"' 'manuskript/version.py' | cut -d\" -f2)" + snapcraftctl set-version "$version" + stage-packages: + - python3 + - python3-pyqt5 + - python3-pyqt5.qtwebkit + - libqt5svg5 + - python3-lxml + - zlib1g + - libc-bin + - locales + - python3-enchant + - python3-markdown + - pandoc + - texlive-latex-recommended + - texlive-fonts-recommended + - texlive-luatex + override-build: | + snapcraftctl build diff --git a/util/fix_ts.py b/util/fix_ts.py new file mode 100644 index 0000000..388cbcc --- /dev/null +++ b/util/fix_ts.py @@ -0,0 +1,65 @@ +#!/bin/python + +import re +import sys + +from lxml import etree + +def main(argv) -> int: + if len(argv) < 2: + print("You need to specify a .ts file!") + return 1 + + path = argv[1] + + if (len(path) < 3) or (path[-3:] != '.ts'): + print("Please specify a path to a .ts file!") + return 2 + + tree = None + + with open(path, 'rb') as file: + tree = etree.parse(file) + + if tree is None: + print("Parsing failed!") + return 3 + + root = tree.getroot() + if root.tag != 'TS': + print("Wrong type of file!") + return 4 + + for context in root.getchildren(): + if context.tag != 'context': + continue + + for message in context.getchildren(): + if message.tag != 'message': + continue + + source = message.find("source") + translation = message.find("translation") + + if (source is None) or (translation is None): + continue + + sourceText = etree.tostring(source, encoding='unicode').strip() + + if '&' in sourceText: + continue + + translationText = etree.tostring(translation, encoding='unicode').strip() + translationText = re.sub(r'&([a-zA-Z]+);', r'&\g<1>;', translationText) + + translationNode = etree.fromstring(translationText) + translation.text = translationNode.text + + with open(path, 'wb') as file: + tree.write(file, encoding='utf-8', xml_declaration=True, pretty_print=True) + + print("Fixing finished!") + return 0 + +if __name__ == '__main__': + sys.exit(main(sys.argv))ch.theologeek.Manuskript +CC0-1.0 +GPL-3.0 ++ + Manuskript +An open-source tool for writers ++ + +Manuskript provides a rich environment to help writers create their first draft and then further refine and edit their masterpiece.
+With Manuskript you can:
++
+- Grow your premise from one sentence, to a paragraph, to a full summary
+- Create characters
+- Conceive plots
+- Construct outlines (Outline mode and/or Index cards)
+- Write with focus (Distraction free mode)
+- Build worlds
+- Track items
+- Edit and re-organize chapters and scenes
+- View Story line
+- Compose with fiction or non-fiction templates and writing modes
+- Import and export document formats such as HTML, ePub, OpenDocument, DocX, and more
++ + ++ +https://www.theologeek.ch/manuskript/wp-content/uploads/2022/08/screenshot01.png ++ +https://www.theologeek.ch/manuskript/wp-content/uploads/2022/08/screenshot02.png ++ +https://www.theologeek.ch/manuskript/wp-content/uploads/2022/08/screenshot03.png ++ +https://www.theologeek.ch/manuskript/wp-content/uploads/2022/08/screenshot04.png ++ +https://www.theologeek.ch/manuskript/wp-content/uploads/2022/08/screenshot05.png +http://www.theologeek.ch/manuskript +https://github.com/olivierkes/manuskript/issues +https://www.theologeek.ch/manuskript/category/features + +https://hosted.weblate.org/projects/manuskript/translations +https://github.com/olivierkes/manuskript +https://github.com/olivierkes/manuskript/wiki#contributing + +thejackimonster_at_gmail.com + +ch.theologeek.Manuskript.desktop ++ ++ + +