A universal document converter. Can be used to convert markdown to a wide range of other + description = qApp.translate("Export", """
A universal document converter. Can be used to convert Markdown to a wide range of other formats.
Website: http://pandoc.org/
""") @@ -48,7 +48,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: + print("Error: no command for pandoc") + return None + args = [run] + args if outputfile: args.append("--output={}".format(outputfile)) @@ -67,6 +74,12 @@ class pandocExporter(basicExporter): if var and item and item.text().strip(): args.append("--variable={}:{}".format(var, item.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)) p = subprocess.Popen( @@ -83,8 +96,11 @@ class pandocExporter(basicExporter): qApp.restoreOverrideCursor() - if stderr: - err = stderr.decode("utf-8") + 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) return None diff --git a/manuskript/exporter/pandoc/abstractOutput.py b/manuskript/exporter/pandoc/abstractOutput.py index 880d3199..506b0fab 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 27479a59..5cfea996 100644 --- a/manuskript/exporter/pandoc/abstractPlainText.py +++ b/manuskript/exporter/pandoc/abstractPlainText.py @@ -1,5 +1,7 @@ #!/usr/bin/env python # --!-- coding: utf8 --!-- +import re + from PyQt5.QtGui import QTextCharFormat, QFont from PyQt5.QtWidgets import qApp, QVBoxLayout, QCheckBox, QWidget, QHBoxLayout, QLabel, QSpinBox, QComboBox @@ -14,12 +16,20 @@ class abstractPlainText(markdown): toFormat = "SUBCLASSME" icon = "SUBCLASSME" exportFilter = "SUBCLASSME" + exportDefaultSuffix = ".SUBCLASSME" def __init__(self, exporter): self.exporter = exporter def settingsWidget(self): - w = pandocSettings(self, toFormat=self.toFormat) + # Get pandoc major version to determine valid command line options + p = re.compile(r'pandoc (\d+)\..*') + m = p.match(self.exporter.version()) + if m: + majorVersion = m.group(1) + else: + majorVersion = "" + w = pandocSettings(self, majorVersion, toFormat=self.toFormat) w.loadSettings() return w @@ -92,8 +102,10 @@ class pandocSettings(markdownSettings): "TOC-depth": pandocSetting("--toc-depth=", "number", "", qApp.translate("Export", "Number of sections level to include in TOC: "), default=3, min=1, max=6), + # pandoc v1 only "smart": pandocSetting("--smart", "checkbox", "", qApp.translate("Export", "Typographically correct output")), + # pandoc v1 only "normalize": pandocSetting("--normalize", "checkbox", "", qApp.translate("Export", "Normalize the document (cleaner)")), "base-header": pandocSetting("--base-header-level=", "number", "", @@ -111,9 +123,14 @@ class pandocSettings(markdownSettings): qApp.translate("Export", "Self-contained HTML files, with no dependencies")), "q-tags": pandocSetting("--html-q-tags", "checkbox", "html", qApp.translate("Export", "Usetags for quotes in HTML")), + # pandoc v1 only "latex-engine": pandocSetting("--latex-engine=", "combo", "pdf", qApp.translate("Export", "LaTeX engine used to produce the PDF."), vals="pdflatex|lualatex|xelatex"), + # pandoc v2 + "pdf-engine": pandocSetting("--pdf-engine=", "combo", "pdf", + qApp.translate("Export", "LaTeX engine used to produce the PDF."), + vals="pdflatex|lualatex|xelatex"), "epub3": pandocSetting("EXTepub3", "checkbox", "epub", qApp.translate("Export", "Convert to ePUB3")), } @@ -138,17 +155,24 @@ class pandocSettings(markdownSettings): } - def __init__(self, _format, toFormat=None, parent=None): + def __init__(self, _format, majorVersion="", toFormat=None, parent=None): markdownSettings.__init__(self, _format, parent) self.format = toFormat + self.majorVersion = majorVersion w = QWidget(self) w.setLayout(QVBoxLayout()) self.grpPandocGeneral = self.collapsibleGroupBox(self.tr("General"), w) - self.addSettingsWidget("smart", self.grpPandocGeneral) - self.addSettingsWidget("normalize", self.grpPandocGeneral) + 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) @@ -164,7 +188,14 @@ class pandocSettings(markdownSettings): self.addSettingsWidget("atx", self.grpPandocSpecific) self.addSettingsWidget("self-contained", self.grpPandocSpecific) self.addSettingsWidget("q-tags", self.grpPandocSpecific) - self.addSettingsWidget("latex-engine", 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 diff --git a/manuskript/exporter/pandoc/outputFormats.py b/manuskript/exporter/pandoc/outputFormats.py index fb594012..9fa00a83 100644 --- a/manuskript/exporter/pandoc/outputFormats.py +++ b/manuskript/exporter/pandoc/outputFormats.py @@ -13,6 +13,7 @@ class ePub(abstractOutput): exportVarName = "lastPandocePub" toFormat = "epub" exportFilter = "ePub files (*.epub);; Any files (*)" + exportDefaultSuffix = ".epub" class OpenDocument(abstractOutput): @@ -23,6 +24,7 @@ class OpenDocument(abstractOutput): toFormat = "odt" icon = "application-vnd.oasis.opendocument.text" exportFilter = "OpenDocument files (*.odt);; Any files (*)" + exportDefaultSuffix = ".odt" class DocX(abstractOutput): @@ -33,4 +35,5 @@ class DocX(abstractOutput): 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 501789ba..be4eb94b 100644 --- a/manuskript/exporter/pandoc/plainText.py +++ b/manuskript/exporter/pandoc/plainText.py @@ -14,6 +14,7 @@ class markdown(abstractPlainText): exportVarName = "lastPandocMarkdown" toFormat = "markdown" exportFilter = "Markdown files (*.md);; Any files (*)" + exportDefaultSuffix = ".md" class reST(abstractPlainText): @@ -24,6 +25,7 @@ class reST(abstractPlainText): toFormat = "rst" icon = "text-plain" exportFilter = "reST files (*.rst);; Any files (*)" + exportDefaultSuffix = ".rst" class latex(abstractPlainText): @@ -35,6 +37,7 @@ class latex(abstractPlainText): toFormat = "latex" icon = "text-x-tex" exportFilter = "Tex files (*.tex);; Any files (*)" + exportDefaultSuffix = ".tex" class OPML(abstractPlainText): @@ -47,5 +50,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 9a1ea876..8d491921 100644 --- a/manuskript/functions/__init__.py +++ b/manuskript/functions/__init__.py @@ -9,7 +9,7 @@ from PyQt5.QtCore import Qt, QRect, QStandardPaths, QObject, QRegExp, QDir from PyQt5.QtCore import 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, QTextEdit from manuskript.enums import Outline @@ -23,6 +23,27 @@ def wordCount(text): t = [l for l in t if l] return len(t) +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): if text: @@ -50,6 +71,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) @@ -385,6 +407,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 +441,6 @@ def inspect(): s.lineno, s.function)) print(" " + "".join(s.code_context)) + +# 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 00000000..da41f0db --- /dev/null +++ b/manuskript/functions/spellchecker.py @@ -0,0 +1,429 @@ +#!/usr/bin/env python +# --!-- coding: utf8 --!-- + +import os, gzip, json, glob +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 + + +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 is 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 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 isMisspelled(self, word): + raise NotImplemented + + def getSuggestions(self, word): + raise NotImplemented + + 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 is not 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 is None: + default_locale = QLocale.system().name() + if default_locale is 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 is not 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 is 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 is not 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) + + +# Register the implementations in order of priority +Spellchecker.implementations.append(EnchantDictionary) +Spellchecker.registerImplementation(SymSpellDictionary) +Spellchecker.registerImplementation(PySpellcheckerDictionary) diff --git a/manuskript/importer/folderImporter.py b/manuskript/importer/folderImporter.py index 506a9b31..c36ff43d 100644 --- a/manuskript/importer/folderImporter.py +++ b/manuskript/importer/folderImporter.py @@ -44,7 +44,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 diff --git a/manuskript/importer/markdownImporter.py b/manuskript/importer/markdownImporter.py index 107931ea..d135485e 100644 --- a/manuskript/importer/markdownImporter.py +++ b/manuskript/importer/markdownImporter.py @@ -63,7 +63,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 diff --git a/manuskript/importer/pandocImporters.py b/manuskript/importer/pandocImporters.py index 023a4b08..6e281cc8 100644 --- a/manuskript/importer/pandocImporters.py +++ b/manuskript/importer/pandocImporters.py @@ -38,6 +38,9 @@ class pandocImporter(abstractImporter): r = pandocExporter().run(args) + if r is None: + return None + if formatTo == "opml": return self.opmlImporter.startImport("", parentItem, settingsWidget, fromString=r) diff --git a/manuskript/loadSave.py b/manuskript/loadSave.py index e603a855..e0d95b29 100644 --- a/manuskript/loadSave.py +++ b/manuskript/loadSave.py @@ -54,7 +54,7 @@ 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) diff --git a/manuskript/load_save/version_0.py b/manuskript/load_save/version_0.py index 2e311504..e89aa222 100644 --- a/manuskript/load_save/version_0.py +++ b/manuskript/load_save/version_0.py @@ -178,7 +178,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 diff --git a/manuskript/load_save/version_1.py b/manuskript/load_save/version_1.py index 048ae26f..6c4f91d8 100644 --- a/manuskript/load_save/version_1.py +++ b/manuskript/load_save/version_1.py @@ -118,7 +118,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 is None: + print("Error: cannot save project because there is no current project in the UI.") + return False # File format version files.append(("MANUSKRIPT", "1")) @@ -295,10 +302,8 @@ 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.") diff --git a/manuskript/main.py b/manuskript/main.py index a9428b35..63bc9cf1 100644 --- a/manuskript/main.py +++ b/manuskript/main.py @@ -2,12 +2,13 @@ import faulthandler import os +import platform import sys import manuskript.ui.views.webView -from PyQt5.QtCore import QLocale, QTranslator, QSettings -from PyQt5.QtGui import QIcon -from PyQt5.QtWidgets import QApplication, qApp +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 @@ -36,23 +37,109 @@ def prepare(tests=False): app.setStyle(style) # Translation process - locale = QLocale.system().name() - - appTranslator = QTranslator() + appTranslator = QTranslator(app) # By default: locale - translation = appPath(os.path.join("i18n", "manuskript_{}.qm".format(locale))) - # Load translation from settings + def tryLoadTranslation(translation, source): + """Tries to load and activate a given translation for use.""" + if appTranslator.load(translation, appPath("i18n")): + app.installTranslator(appTranslator) + print("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("No translation found or loaded. ({})".format(translation)) + return False + + def activateTranslation(translation, source): + """Loads the most suitable translation based on the available information.""" + using_builtin_translation = True + + 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: + print("Using the builtin translation.") + + # Load application translation + translation = "" + source = "default" if settings.contains("applicationTranslation"): - translation = appPath(os.path.join("i18n", settings.value("applicationTranslation"))) - print("Found translation in settings:", translation) - - if appTranslator.load(translation): - app.installTranslator(appTranslator) - print(app.tr("Loaded translation: {}.").format(translation)) - + # Use the language configured by the user. + translation = settings.value("applicationTranslation") + source = "user setting" else: - print(app.tr("Note: No translator found or loaded for locale {}.").format(locale)) + # Auto-detect based on system locale. + translation = QLocale().uiLanguages() + source = "available ui languages" + + print("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() is not '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") @@ -79,14 +166,59 @@ def prepare(tests=False): return app, MW -def launch(MW = None): +def launch(app, MW = None): + if MW is 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 len(sys.argv) > 1 and sys.argv[-1] == "--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 run(): @@ -99,7 +231,7 @@ def run(): app, MW = prepare() # 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(app, MW) if __name__ == "__main__": run() diff --git a/manuskript/mainWindow.py b/manuskript/mainWindow.py index 70fb91cd..5afb0faf 100644 --- a/manuskript/mainWindow.py +++ b/manuskript/mainWindow.py @@ -2,12 +2,14 @@ # --!-- coding: utf8 --!-- import imp 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 from manuskript import settings from manuskript.enums import Character, PlotStep, Plot, World, Outline @@ -34,15 +36,10 @@ from manuskript.ui.statusLabel import statusLabel # Spellcheck support from manuskript.ui.views.textEditView import textEditView - -try: - import enchant -except ImportError: - enchant = None - +from manuskript.functions import Spellchecker class MainWindow(QMainWindow, Ui_MainWindow): - dictChanged = pyqtSignal(str) + # dictChanged = pyqtSignal(str) # Tab indexes TabInfos = 0 @@ -62,9 +59,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 @@ -172,10 +170,8 @@ class MainWindow(QMainWindow, Ui_MainWindow): self.actModeGroup = QActionGroup(self) self.actModeSimple.setActionGroup(self.actModeGroup) self.actModeFiction.setActionGroup(self.actModeGroup) - self.actModeSnowflake.setActionGroup(self.actModeGroup) self.actModeSimple.triggered.connect(self.setViewModeSimple) self.actModeFiction.triggered.connect(self.setViewModeFiction) - self.actModeSnowflake.setEnabled(False) # Main Menu:: Tool self.actToolFrequency.triggered.connect(self.frequencyAnalyzer) @@ -280,6 +276,15 @@ class MainWindow(QMainWindow, Ui_MainWindow): break new = new.parent() + 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 + ############################################################################### # SUMMARY ############################################################################### @@ -417,8 +422,8 @@ class MainWindow(QMainWindow, Ui_MainWindow): PlotStep.meta, QHeaderView.ResizeToContents) self.lstSubPlots.verticalHeader().hide() - def updatePlotImportance(self, ID): - imp = self.mdlPlots.getPlotImportanceByID(ID) + def updatePlotImportance(self, row): + imp = self.mdlPlots.getPlotImportanceByRow(row) self.sldPlotImportance.setValue(int(imp)) def changeCurrentSubPlot(self, index): @@ -552,9 +557,9 @@ 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. Try again.").format(project)) + print(self.tr("The file {} does not exist. Has it been moved or deleted?").format(project)) F.statusMessage( - self.tr("The file {} does not exist. Try again.", importance=3).format(project)) + self.tr("The file {} does not exist. Has it been moved or deleted?").format(project), importance=3) return if loadFromFile: @@ -625,38 +630,76 @@ class MainWindow(QMainWindow, Ui_MainWindow): # We force to emit even if it opens on the current tab self.tabMain.currentChanged.emit(settings.lastTab) + # Make sure we can update the window title later. + self.currentProject = project + self.projectDirty = False + QSettings().setValue("lastProject", project) + # 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")) + self.setWindowTitle(self.projectName() + " - " + self.tr("Manuskript")) # Stuff # self.checkPersosID() # Shouldn't be necessary any longer - self.currentProject = project - QSettings().setValue("lastProject", project) - # 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() @@ -718,23 +761,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() @@ -742,14 +768,42 @@ 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() + # 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? + # 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() @@ -764,11 +818,24 @@ 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 is 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. + print("Bug: 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) else: @@ -1075,14 +1142,17 @@ class MainWindow(QMainWindow, Ui_MainWindow): # HELP ############################################################################### + def centerChildWindow(self, win): + r = win.geometry() + r2 = self.geometry() + win.move(r2.center() - QPoint(r.width()/2, r.height()/2)) + 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 @@ -1248,24 +1318,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 @@ -1273,37 +1348,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 is 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 @@ -1328,9 +1441,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() @@ -1342,6 +1453,7 @@ class MainWindow(QMainWindow, Ui_MainWindow): def frequencyAnalyzer(self): self.fw = frequencyAnalyzer(self) self.fw.show() + self.centerChildWindow(self.fw) ############################################################################### # VIEW MENU @@ -1470,17 +1582,41 @@ 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. + print("WARNING:", warning1, 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 1bef8489..f3fa049b 100644 --- a/manuskript/models/abstractItem.py +++ b/manuskript/models/abstractItem.py @@ -9,18 +9,22 @@ 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 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 = {} @@ -101,7 +105,7 @@ class abstractItem(): return self._data[self.enum.type] ####################################################################### - # Parent / Children managment + # Parent / Children management ####################################################################### def child(self, row): @@ -122,6 +126,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) @@ -140,6 +145,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,6 +185,11 @@ class abstractItem(): item.setData(self.enum.ID, None) return item + def siblings(self): + if self.parent(): + return self.parent().children() + return [] + ############################################################################### # IDS ############################################################################### @@ -249,6 +262,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 +279,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 4240a189..a5360846 100644 --- a/manuskript/models/abstractModel.py +++ b/manuskript/models/abstractModel.py @@ -92,9 +92,15 @@ class abstractModel(QAbstractItemModel): """ 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) @@ -104,9 +110,12 @@ class abstractModel(QAbstractItemModel): 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 +128,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 \ + (parentItem is None) or (parentItem.parent() is None): return QModelIndex() return self.createIndex(parentItem.row(), 0, parentItem) diff --git a/manuskript/models/outlineItem.py b/manuskript/models/outlineItem.py index c465b55c..2af2e15a 100644 --- a/manuskript/models/outlineItem.py +++ b/manuskript/models/outlineItem.py @@ -83,6 +83,15 @@ class outlineItem(abstractItem): 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__ ####################################################################### # Data @@ -173,13 +182,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)) @@ -217,12 +224,11 @@ 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.goalPercentage]) if self.parent(): - self.parent().updateWordCount(emit) + self.parent().updateWordCount() def stats(self): wc = self.data(enums.Outline.wordCount) @@ -232,12 +238,12 @@ class outlineItem(abstractItem): wc = 0 if goal: return qApp.translate("outlineItem", "{} words / {} ({})").format( - locale.format("%d", wc, grouping=True), - locale.format("%d", goal, grouping=True), + 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( - locale.format("%d", wc, grouping=True)) + locale.format_string("%d", wc, grouping=True)) ####################################################################### # Tools: split and merge @@ -482,7 +488,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 diff --git a/manuskript/models/plotModel.py b/manuskript/models/plotModel.py index 6b634ea0..fe01bd0e 100644 --- a/manuskript/models/plotModel.py +++ b/manuskript/models/plotModel.py @@ -68,10 +68,9 @@ class plotModel(QStandardItemModel): return name return None - def getPlotImportanceByID(self, ID): + def getPlotImportanceByRow(self, row): for i in range(self.rowCount()): - _ID = self.item(i, Plot.ID).text() - if _ID == ID or toInt(_ID) == ID: + if i == row: importance = self.item(i, Plot.importance).text() return importance return "0" # Default to "Minor" diff --git a/manuskript/settings.py b/manuskript/settings.py index 985a35d6..96a658e1 100644 --- a/manuskript/settings.py +++ b/manuskript/settings.py @@ -34,6 +34,12 @@ viewSettings = { }, } +fullscreenSettings = { + "autohide-top": True, + "autohide-bottom": True, + "autohide-left": True, + } + # Application spellcheck = False dict = None @@ -79,7 +85,7 @@ textEditor = { } revisions = { - "keep": True, + "keep": False, "smartremove": True, "rules": collections.OrderedDict({ 10 * 60: 60, # One per minute for the last 10mn @@ -119,10 +125,11 @@ def save(filename=None, protocol=None): global spellcheck, dict, corkSliderFactor, viewSettings, corkSizeFactor, folderView, lastTab, openIndexes, \ 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, @@ -131,6 +138,7 @@ def save(filename=None, protocol=None): "openIndexes": openIndexes, "autoSave":autoSave, "autoSaveDelay":autoSaveDelay, + # TODO: Settings Cleanup Task -- Rename saveOnQuit to saveOnProjectClose -- see PR #615 "saveOnQuit":saveOnQuit, "autoSaveNoChanges":autoSaveNoChanges, "autoSaveNoChangesDelay":autoSaveNoChangesDelay, @@ -199,6 +207,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"] diff --git a/manuskript/settingsWindow.py b/manuskript/settingsWindow.py index 85513235..4ae34bf6 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): @@ -73,10 +69,29 @@ class settingsWindow(QWidget, Ui_Settings): self.cmbTranslation.clear() tr = OrderedDict() tr["English"] = "" - tr["Français"] = "manuskript_fr.qm" - tr["Español"] = "manuskript_es.qm" - tr["Deutsch"] = "manuskript_de.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: @@ -448,12 +463,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() @@ -472,8 +499,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"])) @@ -858,12 +911,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 e31443ce..19c56d60 100644 --- a/manuskript/tests/__init__.py +++ b/manuskript/tests/__init__.py @@ -20,8 +20,8 @@ app, MW = main.prepare(tests=True) # 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 be2403df..09629e47 100644 --- a/manuskript/tests/conftest.py +++ b/manuskript/tests/conftest.py @@ -66,7 +66,6 @@ def MWSampleProject(MW): import shutil shutil.copyfile(src, tf.name) shutil.copytree(src[:-4], tf.name[:-4]) - MW.closeProject() MW.loadProject(tf.name) assert MW.currentProject is not None diff --git a/manuskript/tests/models/test_outlineItem.py b/manuskript/tests/models/test_outlineItem.py index 05a22a72..cc681c0c 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,16 +123,6 @@ 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 k = folder._model folder.setModel(14) diff --git a/manuskript/ui/about.py b/manuskript/ui/about.py index dcbe7716..d7102ae7 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-2017 Olivier Keshavjee
" + + " "*5 + "Copyright © 2015-2020 Olivier Keshavjee
" + " "*5 + """ GNU General Public License Version 3
""" diff --git a/manuskript/ui/cheatSheet.py b/manuskript/ui/cheatSheet.py index 5e9ace05..9d01fd99 100644 --- a/manuskript/ui/cheatSheet.py +++ b/manuskript/ui/cheatSheet.py @@ -71,6 +71,8 @@ class cheatSheet(QWidget, Ui_cheatSheet): def textChanged(self, text): if not text: self.hideList() + self.list.clear() + self.view.setText("") else: self.list.show() @@ -146,10 +148,11 @@ class cheatSheet(QWidget, Ui_cheatSheet): def showInfos(self): self.hideList() - i = self.list.currentItem() - ref = i.data(Qt.UserRole) - if ref: - self.view.setText(Ref.infos(ref)) + if self.list and len(self.txtFilter.text()) != 0: + i = self.list.currentItem() + ref = i.data(Qt.UserRole) + if ref: + self.view.setText(Ref.infos(ref)) def openLink(self, link): Ref.open(link) diff --git a/manuskript/ui/editors/editorWidget.py b/manuskript/ui/editors/editorWidget.py index bd5a1654..ed2cf754 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 b9c8b45c..712d2411 100644 --- a/manuskript/ui/editors/fullScreenEditor.py +++ b/manuskript/ui/editors/fullScreenEditor.py @@ -2,8 +2,8 @@ # --!-- 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 +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 @@ -11,21 +11,19 @@ from PyQt5.QtWidgets import QFrame, QWidget, QPushButton, qApp, QStyle, QComboBo # Spell checker support from manuskript import settings from manuskript.enums import Outline +from manuskript.models import outlineItem from manuskript.functions import allPaths, drawProgress 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 - -try: - import enchant -except ImportError: - enchant = None +from manuskript.functions import Spellchecker class fullScreenEditor(QWidget): def __init__(self, index, parent=None): QWidget.__init__(self, parent) + self.setAttribute(Qt.WA_DeleteOnClose, True) self._background = None self._index = index self._theme = findThemePath(settings.fullScreenTheme) @@ -53,23 +51,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 +126,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 +135,33 @@ 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') + # Connection self._index.model().dataChanged.connect(self.dataChanged) @@ -120,6 +170,15 @@ class fullScreenEditor(QWidget): # self.showMaximized() # self.show() + def __del__(self): + # print("Leaving fullScreenEditor via Destructor event", flush=True) + self.showNormal() + self.close() + + def leaveFullscreen(self): + self.showNormal() + self.close() + def setLocked(self, val): self._locked = val self.btnClose.setVisible(not val) @@ -142,8 +201,8 @@ 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) @@ -197,7 +256,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 +280,17 @@ class fullScreenEditor(QWidget): def keyPressEvent(self, event): if event.key() in [Qt.Key_Escape, Qt.Key_F11] and \ not self._locked: + # print("Leaving fullScreenEditor via keyPressEvent", flush=True) + self.showNormal() self.close() + 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 +334,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 is not None) + self.btnNext.setEnabled(nextItem is not None) + self.wPath.setItem(item) + def updateStatusBar(self): if self._index: item = self._index.internalPointer() @@ -274,18 +351,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 +377,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=qApp.translate("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 +482,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 +529,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 +548,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) is not None: + self._setSettingValue(setting, settings.fullscreenSettings[config_name]) + + def addSetting(self, label, config_name, default=True): + if settings.fullscreenSettings.get(config_name, None) is 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) is 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/mainEditor.py b/manuskript/ui/editors/mainEditor.py index b6fd83e2..2f8e025d 100644 --- a/manuskript/ui/editors/mainEditor.py +++ b/manuskript/ui/editors/mainEditor.py @@ -9,7 +9,7 @@ from PyQt5.QtWidgets import QWidget, qApp 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 @@ -304,7 +304,9 @@ class mainEditor(QWidget, Ui_mainEditor): 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 chars: chars = 0 diff --git a/manuskript/ui/editors/themes.py b/manuskript/ui/editors/themes.py index e7f8cfff..ea7b328e 100644 --- a/manuskript/ui/editors/themes.py +++ b/manuskript/ui/editors/themes.py @@ -137,7 +137,7 @@ def createThemePreview(theme, screenRect, size=QSize(200, 120)): def findThemePath(themeName): p = findFirstFile(re.escape("{}.theme".format(themeName)), "resources/themes") if not p: - return findFirstFile(".*\.theme", "resources/themes") + return findFirstFile(r".*\.theme", "resources/themes") else: return p @@ -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 7a954bc4..7a83f30c 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(r.width()/2, 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 78fd0017..bfa4e2a9 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/highlighters/MMDHighlighter.py b/manuskript/ui/highlighters/MMDHighlighter.py index cea62555..ec6650fe 100644 --- a/manuskript/ui/highlighters/MMDHighlighter.py +++ b/manuskript/ui/highlighters/MMDHighlighter.py @@ -11,19 +11,19 @@ from manuskript.ui.highlighters import BasicHighlighter class MMDHighlighter(BasicHighlighter): MARKDOWN_REGEX = { - 'Bold': '(\*\*)(.+?)(\*\*)', + 'Bold': r'(\*\*)(.+?)(\*\*)', 'Bold2': '(__)(.+?)(__)', - 'Italic': '(\*)([^\*].+?[^\*])(\*)', + 'Italic': r'(\*)([^\*].+?[^\*])(\*)', 'Italic2': '(_)([^_].+?[^_])(_)', - 'Title': '^(#+)(\s*)(.*)(#*)', + 'Title': r'^(#+)(\s*)(.*)(#*)', 'HTML': '<.+?>', 'Blockquotes': '^(> )+.*$', - 'OrderedList': '^\d+\.\s+', - 'UnorderedList': '^[\*\+-]\s+', - 'Code': '^\s{4,}.*$', - 'Links-inline': '(\[)(.*?)(\])(\()(.*?)(\))', - 'Links-ref': '(\[)(.*?)(\])\s?(\[)(.*?)(\])', - 'Links-ref2': '^\s{,3}(\[)(.*?)(\]:)\s+([^\s]*)\s*(.*?)*$', + 'OrderedList': r'^\d+\.\s+', + 'UnorderedList': r'^[\*\+-]\s+', + 'Code': r'^\s{4,}.*$', + 'Links-inline': r'(\[)(.*?)(\])(\()(.*?)(\))', + 'Links-ref': r'(\[)(.*?)(\])\s?(\[)(.*?)(\])', + 'Links-ref2': r'^\s{,3}(\[)(.*?)(\]:)\s+([^\s]*)\s*(.*?)*$', } def __init__(self, editor, style="Default"): diff --git a/manuskript/ui/highlighters/basicHighlighter.py b/manuskript/ui/highlighters/basicHighlighter.py index 6e45eba2..362ee5a2 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 @@ -86,7 +86,7 @@ class BasicHighlighter(QSyntaxHighlighter): def doHighlightBlock(self, text): """ - Virtual funtion to subclass. + Virtual function to subclass. """ pass @@ -146,13 +146,16 @@ class BasicHighlighter(QSyntaxHighlighter): textedText = text + " " # Based on http://john.nachtimwald.com/2009/08/22/qplaintextedit-with-in-line-spell-check/ - WORDS = r'(?iu)(((?!_)[\w\'])+)' + WORDS = r'(?iu)((?:[^_\W]|\')+)[^A-Za-z0-9\']' # (?iu) means case insensitive and Unicode - # (?!_) means perform negative lookahead to exclude "_" from pattern match. See issue #283 + # ((?:[^_\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 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))): + and self.editor._dict.isMisspelled(word_object.group(1))): format = self.format(word_object.start(1)) format.setUnderlineColor(self._misspelledColor) # SpellCheckUnderline fails with some fonts diff --git a/manuskript/ui/highlighters/markdownHighlighter.py b/manuskript/ui/highlighters/markdownHighlighter.py index 786cae8c..4d873b47 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) diff --git a/manuskript/ui/highlighters/markdownTokenizer.py b/manuskript/ui/highlighters/markdownTokenizer.py index 75fb279c..9bae8863 100644 --- a/manuskript/ui/highlighters/markdownTokenizer.py +++ b/manuskript/ui/highlighters/markdownTokenizer.py @@ -91,7 +91,7 @@ class MarkdownTokenizer(HighlightTokenizer): strongRegex.setMinimal(True) strikethroughRegex = QRegExp("~~[^\\s]+.*[^\\s]+~~") strikethroughRegex.setMinimal(True) - superScriptRegex = QRegExp("\^([^\\s]|(\\\\\\s))+\^") # Spaces must be escaped "\ " + superScriptRegex = QRegExp(r"\^([^\s]|(\\\\\s))+\^") # Spaces must be escaped "\ " superScriptRegex.setMinimal(True) subScriptRegex = QRegExp("~([^\\s]|(\\\\\\s))+~") # Spaces must be escaped "\ " subScriptRegex.setMinimal(True) @@ -279,7 +279,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 +365,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 +832,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 @@ -887,7 +889,7 @@ class MarkdownTokenizer(HighlightTokenizer): with the escaped characters replaced with a dummy character. """ - return re.sub("\\\\.", "\$", text) + return re.sub("\\\\.", r"\$", text) #escape = False #escapedText = text diff --git a/manuskript/ui/importers/importer.py b/manuskript/ui/importers/importer.py index 0e378e41..194c2cd3 100644 --- a/manuskript/ui/importers/importer.py +++ b/manuskript/ui/importers/importer.py @@ -36,6 +36,7 @@ class importerDialog(QWidget, Ui_importer): # Var self.mw = mw + self.settingsWidget = None self.fileName = "" self.setStyleSheet(style.mainWindowSS()) self.tree.setStyleSheet("QTreeView{background:transparent;}") @@ -121,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: @@ -195,7 +195,7 @@ class importerDialog(QWidget, Ui_importer): self.grpPreview.setEnabled(True) self.settingsWidget = generalSettings() - #TODO: custom format widget + #TODO: custom format widget to match exporter visuals? self.settingsWidget = F.settingsWidget(self.settingsWidget) # Set the settings widget in place diff --git a/manuskript/ui/importers/importer_ui.py b/manuskript/ui/importers/importer_ui.py index d23c8e4e..59c8a6bd 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 cbf72b95..98ca1df6 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/mainWindow.py b/manuskript/ui/mainWindow.py index 5589962a..76f08228 100644 --- a/manuskript/ui/mainWindow.py +++ b/manuskript/ui/mainWindow.py @@ -1150,9 +1150,6 @@ class Ui_MainWindow(object): self.actModeFiction = QtWidgets.QAction(MainWindow) self.actModeFiction.setCheckable(True) self.actModeFiction.setObjectName("actModeFiction") - self.actModeSnowflake = QtWidgets.QAction(MainWindow) - self.actModeSnowflake.setCheckable(True) - self.actModeSnowflake.setObjectName("actModeSnowflake") self.actViewCork = QtWidgets.QAction(MainWindow) self.actViewCork.setObjectName("actViewCork") self.actViewOutline = QtWidgets.QAction(MainWindow) @@ -1325,7 +1322,6 @@ class Ui_MainWindow(object): self.menuEdit.addAction(self.actSettings) self.menuMode.addAction(self.actModeSimple) self.menuMode.addAction(self.actModeFiction) - self.menuMode.addAction(self.actModeSnowflake) self.menuView.addAction(self.menuMode.menuAction()) self.menuView.addSeparator() self.menuOrganize.addAction(self.actMoveUp) @@ -1343,20 +1339,116 @@ class Ui_MainWindow(object): self.retranslateUi(MainWindow) self.stack.setCurrentIndex(1) - self.tabMain.setCurrentIndex(3) + self.tabMain.setCurrentIndex(0) self.tabSummary.setCurrentIndex(0) - self.tabPersos.setCurrentIndex(2) - self.tabPlot.setCurrentIndex(1) + self.tabPersos.setCurrentIndex(0) + self.tabPlot.setCurrentIndex(0) self.comboBox_2.setCurrentIndex(0) self.stkPlotSummary.setCurrentIndex(0) self.tabWorld.setCurrentIndex(0) - self.tabWidget.setCurrentIndex(2) + self.tabWidget.setCurrentIndex(0) 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) 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.btnStepThree) + MainWindow.setTabOrder(self.btnStepThree, self.btnStepTwo) + MainWindow.setTabOrder(self.btnStepTwo, self.btnStepFive) + MainWindow.setTabOrder(self.btnStepFive, self.btnStepSeven) + MainWindow.setTabOrder(self.btnStepSeven, 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.btnStepFour) + MainWindow.setTabOrder(self.btnStepFour, self.txtPersoSummaryFull) + MainWindow.setTabOrder(self.txtPersoSummaryFull, self.btnStepSix) + MainWindow.setTabOrder(self.btnStepSix, 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.btnStepEight) + MainWindow.setTabOrder(self.btnStepEight, 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 @@ -1418,10 +1510,8 @@ class Ui_MainWindow(object): self.label_28.setText(_translate("MainWindow", "Result")) self.tabPlot.setTabText(self.tabPlot.indexOf(self.infos_2), _translate("MainWindow", "Basic info")) self.grpSubPlotSummary.setTitle(_translate("MainWindow", "Summary:")) - self.btnAddSubPlot.setToolTip(_translate("MainWindow", "Add plot step (CTRL+Enter)")) - self.btnAddSubPlot.setShortcut(_translate("MainWindow", "Ctrl+Return")) - self.btnRmSubPlot.setToolTip(_translate("MainWindow", "Remove selected plot step(s) (CTRL+Backspace)")) - self.btnRmSubPlot.setShortcut(_translate("MainWindow", "Ctrl+Backspace")) + self.btnAddSubPlot.setToolTip(_translate("MainWindow", "Add plot step")) + self.btnRmSubPlot.setToolTip(_translate("MainWindow", "Remove selected plot step(s)")) self.tabPlot.setTabText(self.tabPlot.indexOf(self.tab_15), _translate("MainWindow", "Resolution steps")) self.grpPlotSummary.setTitle(_translate("MainWindow", "Summary")) self.comboBox_2.setItemText(0, _translate("MainWindow", "One paragraph")) @@ -1477,7 +1567,6 @@ class Ui_MainWindow(object): self.actViewTree.setText(_translate("MainWindow", "Tree")) self.actModeSimple.setText(_translate("MainWindow", "&Simple")) self.actModeFiction.setText(_translate("MainWindow", "&Fiction")) - self.actModeSnowflake.setText(_translate("MainWindow", "S&nowflake")) self.actViewCork.setText(_translate("MainWindow", "Index cards")) self.actViewOutline.setText(_translate("MainWindow", "Outline")) self.actSettings.setText(_translate("MainWindow", "S&ettings")) diff --git a/manuskript/ui/mainWindow.ui b/manuskript/ui/mainWindow.ui index 182df211..3da6713a 100644 --- a/manuskript/ui/mainWindow.ui +++ b/manuskript/ui/mainWindow.ui @@ -124,7 +124,7 @@ QTabWidget::Rounded - 3 +0 true @@ -774,7 +774,7 @@- 2 +0 @@ -1173,7 +1173,7 @@ - 1 +0 true @@ -1348,7 +1348,7 @@- Add plot step (CTRL+Enter) +Add plot step - @@ -1357,9 +1357,6 @@ . .- Ctrl+Return -@@ -1368,7 +1365,7 @@ true - Remove selected plot step(s) (CTRL+Backspace) +Remove selected plot step(s) - @@ -1377,9 +1374,6 @@ . .- Ctrl+Backspace -@@ -1997,7 +1991,7 @@ true QTabWidget::West - 2 +0 @@ -2207,7 +2201,6 @@ - @@ -2459,14 +2452,6 @@ &Fiction -- - -true -- -S&nowflake -Index cards @@ -2914,6 +2899,105 @@1 ++ tabMain +txtGeneralTitle +txtGeneralSubtitle +txtGeneralSerie +txtGeneralVolume +txtGeneralGenre +txtGeneralLicense +txtGeneralAuthor +txtGeneralEmail +cmbSummary +txtSummarySentence +txtSummarySentence_2 +txtSummaryPara +txtSummaryPara_2 +txtSummaryPage +txtSummaryPage_2 +txtSummaryFull +btnStepThree +btnStepTwo +btnStepFive +btnStepSeven +txtSummarySituation +lstCharacters +btnAddPerso +btnRmPerso +txtPersosFilter +tabPersos +scrollAreaPersoInfos +txtPersoName +btnPersoColor +txtPersoMotivation +txtPersoGoal +txtPersoConflict +txtPersoEpiphany +txtPersoSummarySentence +txtPersoSummaryPara +btnStepFour +txtPersoSummaryFull +btnStepSix +txtPersoNotes +tblPersoInfos +btnPersoAddInfo +btnPersoRmInfo +lineEdit +btnStepEight +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/settings_ui.py b/manuskript/ui/settings_ui.py index d958116d..0cd39f5b 100644 --- a/manuskript/ui/settings_ui.py +++ b/manuskript/ui/settings_ui.py @@ -2,12 +2,14 @@ # 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.13.0 # # WARNING! All changes made in this file will be lost! + from PyQt5 import QtCore, QtGui, QtWidgets + class Ui_Settings(object): def setupUi(self, Settings): Settings.setObjectName("Settings") @@ -288,6 +290,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 +327,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 +343,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 +359,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 +375,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") @@ -383,6 +390,11 @@ class Ui_Settings(object): self.verticalLayout.addWidget(self.chkRevisionRemove) spacerItem3 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) self.verticalLayout.addItem(spacerItem3) + 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") @@ -1532,7 +1544,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 +1575,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 +1672,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 +1721,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") @@ -1846,7 +1854,7 @@ class Ui_Settings(object): 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.label_2.setText(_translate("Settings", "Restarting Manuskript ensures all settings take effect.")) 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 +1862,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 +1876,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:")) @@ -2075,4 +2084,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 dc745c49..6563bcbb 100644 --- a/manuskript/ui/settings_ui.ui +++ b/manuskript/ui/settings_ui.ui @@ -179,7 +179,7 @@- You might need to restart manuskript in order for those settings to take effect properly and entirely. +Restarting Manuskript ensures all settings take effect. true @@ -407,7 +407,7 @@- Save on quit +Save on project close true @@ -423,7 +423,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 +578,9 @@ + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + 1 +@@ -642,6 +645,9 @@ 999 + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + 1 +@@ -667,6 +673,9 @@ 999 + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + 1 +@@ -692,6 +701,9 @@ 999 + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + 1 +@@ -717,6 +729,9 @@ 999 + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + 1 +@@ -754,6 +769,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 +diff --git a/manuskript/ui/tools/frequency_ui.py b/manuskript/ui/tools/frequency_ui.py index cf2058ec..29d105ff 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 5e341184..64e0b9b7 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 5be00afd..e0db6808 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="", diff --git a/manuskript/ui/views/MDEditView.py b/manuskript/ui/views/MDEditView.py index cba07a26..e8eb5644 100644 --- a/manuskript/ui/views/MDEditView.py +++ b/manuskript/ui/views/MDEditView.py @@ -18,12 +18,12 @@ from manuskript import functions as F class MDEditView(textEditView): blockquoteRegex = QRegExp("^ {0,3}(>\\s*)+") - listRegex = QRegExp("^(\\s*)([+*-]|([0-9a-z])+([.\)]))(\\s+)") + listRegex = QRegExp(r"^(\s*)([+*-]|([0-9a-z])+([.\)]))(\s+)") inlineLinkRegex = QRegExp("\\[([^\n]+)\\]\\(([^\n]+)\\)") 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 +243,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 +294,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 +303,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(): @@ -342,15 +342,15 @@ class MDEditView(textEditView): def clearedFormat(self, text): # FIXME: clear also block formats for reg, rep, flags in [ - ("\*\*(.*?)\*\*", "\\1", None), # bold + (r"\*\*(.*?)\*\*", "\\1", None), # bold ("__(.*?)__", "\\1", None), # bold - ("\*(.*?)\*", "\\1", None), # emphasis + (r"\*(.*?)\*", "\\1", None), # emphasis ("_(.*?)_", "\\1", None), # emphasis ("`(.*?)`", "\\1", None), # verbatim ("~~(.*?)~~", "\\1", None), # strike - ("\^(.*?)\^", "\\1", None), # superscript + (r"\^(.*?)\^", "\\1", None), # superscript ("~(.*?)~", "\\1", None), # subscript - ("", "\\1", re.S), # comments + (r"", "\\1", re.S), # comments # LINES OR BLOCKS (r"^#*\s*(.+?)\s*", "\\1", re.M), # ATX @@ -394,7 +394,7 @@ class MDEditView(textEditView): c.insertText("") char = "=" if level == 1 else "-" - text = re.sub("^#*\s*(.*)\s*#*", "\\1", text) # Removes # + text = re.sub(r"^#*\s*(.*)\s*#*", "\\1", text) # Removes # sub = char * len(text) text = text + "\n" + sub @@ -429,7 +429,7 @@ class MDEditView(textEditView): self.titleATX(level) return - m = re.match("^(#+)(\s*)(.+)", text) + m = re.match(r"^(#+)(\s*)(.+)", text) if m: pre = m.group(1) space = m.group(2) @@ -527,7 +527,7 @@ 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] @@ -582,53 +582,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. + print("Warning: unable to match fetched data for tooltip to original request.") + print("- Completed request:", url_key) + print("- Status upon finishing:", reply.error(), reply.errorString()) + print("- Currently processing:", 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/corkView.py b/manuskript/ui/views/corkView.py index c27c3cb7..e77e49db 100644 --- a/manuskript/ui/views/corkView.py +++ b/manuskript/ui/views/corkView.py @@ -36,7 +36,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/lineEditView.py b/manuskript/ui/views/lineEditView.py index 1df64c57..0366016a 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 @@ -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 2dfa9ff1..833652e7 100644 --- a/manuskript/ui/views/outlineBasics.py +++ b/manuskript/ui/views/outlineBasics.py @@ -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) @@ -383,7 +383,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) diff --git a/manuskript/ui/views/propertiesView_ui.py b/manuskript/ui/views/propertiesView_ui.py index e4846fae..29014672 100644 --- a/manuskript/ui/views/propertiesView_ui.py +++ b/manuskript/ui/views/propertiesView_ui.py @@ -20,7 +20,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) diff --git a/manuskript/ui/views/propertiesView_ui.ui b/manuskript/ui/views/propertiesView_ui.ui index 4e1549fe..9f7d6019 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/textEditView.py b/manuskript/ui/views/textEditView.py index c2ec52ce..41324eb5 100644 --- a/manuskript/ui/views/textEditView.py +++ b/manuskript/ui/views/textEditView.py @@ -3,7 +3,7 @@ import re 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 @@ -13,15 +13,10 @@ 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 - -try: - import enchant -except ImportError: - enchant = None - +from manuskript.functions import Spellchecker 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 +24,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 +34,9 @@ class textEditView(QTextEdit): self._themeData = None self._highlighterClass = BasicHighlighter + if spellcheck is None: + spellcheck = settings.spellcheck + self.spellcheck = spellcheck self.currentDict = dict if dict else settings.dict self._defaultFontSize = qApp.font().pointSize() @@ -74,29 +72,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: @@ -162,7 +147,7 @@ class textEditView(QTextEdit): self.setEnabled(True) if i.column() != self._column: i = i.sibling(i.row(), self._column) - self._indexes.append(QPersistentModelIndex(i)) + self._indexes.append(QModelIndex(i)) if not self._model: self.setModel(i.model()) @@ -252,11 +237,9 @@ 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 @@ -268,15 +251,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: @@ -288,10 +271,9 @@ class textEditView(QTextEdit): self.document().contentsChanged.connect(self.updateTimer.start, F.AUC) def updateText(self): - if self._updating: - return + self._updating.lock() + # print("Updating", self.objectName()) - self._updating = True if self._index: self.disconnectDocument() if self.toPlainText() != F.toString(self._index.data()): @@ -322,30 +304,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 + + self._updating.lock() + text = self.toPlainText() + self._updating.unlock() + # print("Submitting", self.objectName()) if self._index and self._index.isValid(): # item = self._index.internalPointer() - if self.toPlainText() != self._index.data(): + if text != self._index.data(): # print(" Submitting plain text") - self._updating = True - self._model.setData(QModelIndex(self._index), - self.toPlainText()) - self._updating = False + 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)): + if text != F.toString(item.data(self._column)): print("Submitting many indexes") - self._model.setData(i, self.toPlainText()) - self._updating = False + self._model.setData(i, text) def keyPressEvent(self, event): if event.key() == Qt.Key_V and event.modifiers() & Qt.ControlModifier: @@ -386,20 +367,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() @@ -470,33 +449,33 @@ class textEditView(QTextEdit): # Check if the selected word is misspelled and offer spelling # suggestions if it is. - if cursor.hasSelection(): + if self._dict and cursor.hasSelection(): text = str(cursor.selectedText()) - valid = self._dict.check(text) + valid = not self._dict.isMisspelled(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): + for word in self._dict.getSuggestions(text): action = self.SpellAction(word, spell_menu) action.correct.connect(self.correctWord) spell_menu.addAction(action) + popup_menu.insertSeparator(popup_menu.actions()[0]) + # 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) # 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]) - # 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]) # If word was added to custom dict, give the possibility to remove it - elif valid and self._dict.is_added(selectedWord): + elif valid and 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) @@ -521,12 +500,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/welcome.py b/manuskript/ui/welcome.py index 964d2f89..8055c3a8 100644 --- a/manuskript/ui/welcome.py +++ b/manuskript/ui/welcome.py @@ -53,6 +53,18 @@ class welcome(QWidget, Ui_welcome): # Recent Files self.loadRecents() + def getLastAccessedDirectory(self): + sttgs = QSettings() + lastDirectory = sttgs.value("lastAccessedDirectory", defaultValue=".", type=str) + if lastDirectory != '.': + print(qApp.translate("lastAccessedDirectoryInfo", "Last accessed directory \"{}\" loaded.").format( + lastDirectory)) + return lastDirectory + + def setLastAccessedDirectory(self, dir): + sttgs = QSettings() + sttgs.setValue("lastAccessedDirectory", dir) + ############################################################################### # AUTOLOAD ############################################################################### @@ -138,24 +150,30 @@ class welcome(QWidget, Ui_welcome): ############################################################################### def openFile(self): + lastDirectory = self.getLastAccessedDirectory() + """File dialog that request an existing file. For opening project.""" filename = QFileDialog.getOpenFileName(self, self.tr("Open project"), - ".", + lastDirectory, self.tr("Manuskript project (*.msk);;All files (*)"))[0] if filename: + self.setLastAccessedDirectory(os.path.dirname(filename)) self.appendToRecentFiles(filename) self.mw.loadProject(filename) def saveAsFile(self): + lastDirectory = self.getLastAccessedDirectory() + """File dialog that request a file, existing or not. Save data to that file, which then becomes the current project.""" filename = QFileDialog.getSaveFileName(self, self.tr("Save project as..."), - ".", + lastDirectory, self.tr("Manuskript project (*.msk)"))[0] if filename: + self.setLastAccessedDirectory(os.path.dirname(filename)) if filename[-4:] != ".msk": filename += ".msk" self.appendToRecentFiles(filename) @@ -168,16 +186,19 @@ class welcome(QWidget, Ui_welcome): self.mw.setWindowTitle(pName + " - " + self.tr("Manuskript")) def createFile(self, filename=None, overwrite=False): + lastDirectory = self.getLastAccessedDirectory() + """When starting a new project, ask for a place to save it. Datas are not loaded from file, so they must be populated another way.""" if not filename: filename = QFileDialog.getSaveFileName( self, self.tr("Create New Project"), - ".", + lastDirectory, self.tr("Manuskript project (*.msk)"))[0] if filename: + self.setLastAccessedDirectory(os.path.dirname(filename)) if filename[-4:] != ".msk": filename += ".msk" if os.path.exists(filename) and not overwrite: @@ -265,18 +286,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: @@ -339,18 +361,27 @@ 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 is not None : + self.template[1][templateIndex] = ( + self.template[1][templateIndex][0], + t.text()) + if total == 1: total = 0 self.lblTotal.setText(self.tr("Total: {} words (~ {} pages)").format( - locale.format("%d", total, grouping=True), - locale.format("%d", total / 250, grouping=True) + locale.format_string("%d", total, grouping=True), + locale.format_string("%d", total / 250, grouping=True) )) def addTopLevelItem(self, name): diff --git a/manuskript/version.py b/manuskript/version.py index e95faa7c..beafddc1 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.6.0" +__version__ = "0.11.0" def getVersion(): return __version__ diff --git a/package/build_osx.sh b/package/build_osx.sh new file mode 100755 index 00000000..299dbcd2 --- /dev/null +++ b/package/build_osx.sh @@ -0,0 +1,10 @@ +#!/bin/bash +set -ev +if [ z"$FILENAME" = "z" ]; then + echo "Error: Environment variable FILENAME is not set" + exit 1 +fi +pyinstaller manuskript.spec --clean +cd dist && zip $FILENAME -r manuskript && cd .. +ls dist +cp dist/$FILENAME dist/manuskript-osx-develop.zip diff --git a/package/create_rpm.sh b/package/create_rpm.sh new file mode 100755 index 00000000..47300b57 --- /dev/null +++ b/package/create_rpm.sh @@ -0,0 +1,90 @@ +#!/bin/bash +# +# Name: create_rpm.sh +# +# Description: Create Fedora style RPM package for Manuskript +# +# Usage: create_rpm.sh [AppVersion PkgNumber] +# +# Parameters: Appversion - defaults to manuskript/version.py value. +# PkgNumber - default to 1. + + +# Function to echo command and then run command +# Usage: echo_do eval "command-to-run" +function echo_do() { + echo "\$ $@" | sed 's/eval //' + "$@" +} + +# Program vars +ScriptPath="$( cd "$(dirname "$0")" ; pwd -P )" +Root="$ScriptPath/../" + +# Manuskript Vars +AppName=manuskript +Version=$(grep -E "__version__.*\".*\"" "$Root/manuskript/version.py" \ + | cut -d\" -f2) # Look for version in manuskript/version +AppVersion=${1:-$Version} +PkgNumber=${2:-1} +PkgVersion=$AppVersion-$PkgNumber +Dest="$Root/rpmbuild" + +echo "### Using package directory: $Dest" + +echo "### Creating folder structure" +echo_do eval "mkdir -p $Dest/{BUILD,RPMS,SOURCES,SPECS,tmp}" +echo_do eval "mkdir -p $Dest/RPMS/noarch" + +echo "### Defining rpm macros" +cat <~/.rpmmacros +%_topdir %(echo $Dest) +%_tmppath %{_topdir}/tmp +EOF + +# Getting manuskript files, by downloading +# pushd $Dest/ +# wget https://github.com/olivierkes/manuskript/archive/$AppVersion.tar.gz +# tar -xvf $AppVersion.tar.gz +# rm $AppVersion.tar.gz +# popd + +# Using the current direction as source + +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' \ + --exclude-from='$Root/.gitignore' \ + --exclude=rpmbuild \ + --exclude={.codeclimate.yml,.gitignore,.travis.yml} \ + $ScriptPath/../ $Dest/$AppName-$AppVersion/usr/share/manuskript" +# Note: Files manuskript and manuskript.desktop are same as in Debian +echo_do eval "cp $ScriptPath/create_deb/manuskript $Dest/$AppName-$AppVersion/usr/bin/manuskript" +echo_do eval "chmod 0755 $Dest/$AppName-$AppVersion/usr/bin/manuskript" +echo_do eval "cp $ScriptPath/create_deb/manuskript.desktop \ + $Dest/$AppName-$AppVersion/usr/share/applications/manuskript.desktop" + +echo "### Creating SPECS/manuskript.spec file" +echo_do eval "cp $ScriptPath/create_rpm/manuskript.spec \ + $Dest/SPECS/manuskript.spec" +echo_do eval "sed -i \"s/{AppVersion}/$AppVersion/\" \ + $Dest/SPECS/manuskript.spec" +echo_do eval "sed -i \"s/{PkgNumber}/$PkgNumber/\" \ + $Dest/SPECS/manuskript.spec" + +echo "### Creating tarball" +echo_do eval "tar -C $Dest -cf $Dest/SOURCES/$AppName-$AppVersion.tar.gz \ + $AppName-$AppVersion" + +echo "### Removing temporary tarball directory" +echo_do eval "rm -rf $Dest/$AppName-$AppVersion" + +echo "### Building the RPM package…" +echo_do eval "pushd $Dest" +echo_do eval "rpmbuild --target=noarch -bb SPECS/manuskript.spec" +echo_do eval "popd" + +echo "### Done" +echo "### RPM File: $Dest/RPMS/noarch/$AppName-$PkgVersion.noarch.rpm" diff --git a/package/create_rpm/manuskript.spec b/package/create_rpm/manuskript.spec new file mode 100644 index 00000000..57b7e957 --- /dev/null +++ b/package/create_rpm/manuskript.spec @@ -0,0 +1,62 @@ +# Don't try fancy stuff like debuginfo, which is useless on binary-only +# packages. Don't strip binary too +# Be sure buildpolicy set to do nothing +%define name manuskript +%define version {AppVersion} +%define release {PkgNumber} +%define __spec_install_post %{nil} +%define debug_package %{nil} +%define __os_install_post %{_dbpath}/brp-compress + +Summary: Manuskript open source tool for writers +Name: %{name} +Version: %{version} +Release: %{release} +License: GPL3+ +Group: Applications/Editors +BuildArch: noarch +BuildRoot: %{_builddir}/%{name}-%{version}-%{release}-root +URL: http://www.theologeek.ch/manuskript/ +SOURCE0 : %{name}-%{version}.tar.gz +Packager: Curtis Gedak +Provides: Manuskript +Requires: python3, python3-qt5, python3-lxml, zlib, python3-markdown, pandoc +%if 0%{?suse_version} +# Assume openSUSE +# Note - have to build rpm on openSUSE for this to work. +Requires: libQt5Svg5, python3-pyenchant +%else +# Assume Fedora and others +Requires: python3-qt5-webkit, qt5-qtsvg, python3-enchant +%endif + +%description +Manuskript is an open source tool for writers. It +provides a rich environment to help writers create +their first draft and then further refine and edit +their masterpiece. + +%prep +%setup -q + +%build +# Empty section. + +%install +rm -rf %{buildroot} +mkdir -p %{buildroot} + +# in builddir +cp -a * %{buildroot} + +%clean +rm -rf %{buildroot} + +%files +%defattr(-,root,root,-) +/usr/bin/manuskript +/usr/share/applications/manuskript.desktop +/usr/share/manuskript/* + +%changelog +# Empty section. diff --git a/package/dependency_test.py b/package/dependency_test.py deleted file mode 100644 index 9f0d947b..00000000 --- a/package/dependency_test.py +++ /dev/null @@ -1,8 +0,0 @@ -import os -import sys - -realpath = os.path.realpath(__file__) - -sys.path.insert(1, os.path.join(os.path.dirname(realpath), '..')) - -from manuskript import main diff --git a/package/prepare_linux.sh b/package/prepare_linux.sh new file mode 100755 index 00000000..458d9262 --- /dev/null +++ b/package/prepare_linux.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -ev # display each line executed along with output +sudo apt-get -qq update +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.7 +python --version +easy_install pip +pip install pyqt5==5.9 lxml pytest pytest-faulthandler diff --git a/package/prepare_osx.sh b/package/prepare_osx.sh index 95294602..8f937a1e 100755 --- a/package/prepare_osx.sh +++ b/package/prepare_osx.sh @@ -1,9 +1,21 @@ #!/bin/bash -set -ev +set -ev # display each line executed along with output brew update -brew install python3 enchant -sudo pip3 install --upgrade pip setuptools wheel -pip3 install pyinstaller PyQt5 lxml pyenchant -brew install qt hunspell -# fooling PyEnchant as described in the wiki: https://github.com/olivierkes/manuskript/wiki/Package-manuskript-for-OS-X +# Upgrade to python 3.x +brew upgrade python +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 +brew install hunspell +# Fooling PyEnchant as described in the wiki. +# https://github.com/olivierkes/manuskript/wiki/Package-manuskript-for-OS-X sudo touch /usr/local/share/aspell +# +# Note that if qt install is terminated by Travis CI then it is likely +# building from source instead of pouring from a homebrew bottle. +# Fix by choosing lowest osx_image value [1] for xcode that has a +# homebrew qt bottle [2]. +# [1] https://docs.travis-ci.com/user/reference/osx#os-x-version +# [2] https://formulae.brew.sh/formula/qt +brew install qt diff --git a/snap/gui/manuskript.desktop b/snap/gui/manuskript.desktop new file mode 100644 index 00000000..9e105ca6 --- /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/manuskript/current/icons/Manuskript/manuskript.svg +Categories=Office;WordProcessor; diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml new file mode 100644 index 00000000..76df07f2 --- /dev/null +++ b/snap/snapcraft.yaml @@ -0,0 +1,103 @@ +name: manuskript +version: overwritten +version-script: grep -E "__version__.*\".*\"" "manuskript/version.py" | cut -d\" -f2 +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. +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: 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 + +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 + + manuskript: + after: [desktop-qt5] + plugin: dump + source: https://github.com/olivierkes/manuskript.git + source-type: git + build-packages: + - python3 + - python3-pyqt5 + - python3-pyqt5.qtwebkit + - libqt5svg5 + - python3-lxml + - zlib1g + - python3-enchant + - python3-markdown + - pandoc + - texlive-latex-recommended + - texlive-fonts-recommended + - texlive-luatex + 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