Merge branch 'testing' into dev

This commit is contained in:
Veronica K. B. Olsen
2021-02-12 22:07:46 +01:00
10 changed files with 404 additions and 22 deletions
+2 -2
View File
@@ -121,8 +121,8 @@ to quickly navigate between the documents while editing.
# Installing and Running # Installing and Running
For install instructions, please check the [documentation](https://novelwriter.readthedocs.io/) in For install instructions, please check the [Getting Started](https://novelwriter.readthedocs.io/en/latest/int_started.html) section in
the [Getting Started](https://novelwriter.readthedocs.io/en/latest/int_started.html) section. the [documentation](https://novelwriter.readthedocs.io/).
## TLDR Instructions ## TLDR Instructions
+4
View File
@@ -104,6 +104,10 @@ class OptionState():
"countFrom", "countFrom",
"clearDouble", "clearDouble",
}, },
"GuiWordList": {
"winWidth",
"winHeight",
}
} }
return return
+21 -12
View File
@@ -123,6 +123,7 @@ class NWSpellCheck():
if len(theLine) > 0 and theLine not in self.projDict: if len(theLine) > 0 and theLine not in self.projDict:
self.projDict.append(theLine) self.projDict.append(theLine)
logger.debug("Project word list contains %d words" % len(self.projDict)) logger.debug("Project word list contains %d words" % len(self.projDict))
except Exception: except Exception:
logger.error("Failed to load project word list") logger.error("Failed to load project word list")
nw.logException() nw.logException()
@@ -141,6 +142,7 @@ class NWSpellEnchant(NWSpellCheck):
def __init__(self): def __init__(self):
NWSpellCheck.__init__(self) NWSpellCheck.__init__(self)
logger.debug("Enchant spell checking activated") logger.debug("Enchant spell checking activated")
self.theBroker = None
return return
def setLanguage(self, theLang, projectDict=None): def setLanguage(self, theLang, projectDict=None):
@@ -150,9 +152,15 @@ class NWSpellEnchant(NWSpellCheck):
""" """
try: try:
import enchant import enchant
self.theDict = enchant.Dict(theLang) if self.theBroker is not None:
logger.verbose("Deleting old pyenchant broker")
del self.theBroker
self.theBroker = enchant.Broker()
self.theDict = self.theBroker.request_dict(theLang)
self.spellLanguage = theLang self.spellLanguage = theLang
logger.debug("Enchant spell checking for language %s loaded" % theLang) logger.debug("Enchant spell checking for language %s loaded" % theLang)
except Exception: except Exception:
logger.error("Failed to load enchant spell checking for language %s" % theLang) logger.error("Failed to load enchant spell checking for language %s" % theLang)
self.theDict = NWSpellEnchantDummy() self.theDict = NWSpellEnchantDummy()
@@ -238,7 +246,7 @@ class NWSpellSimple(NWSpellCheck):
when no other is available. This method is fairly slow compared to when no other is available. This method is fairly slow compared to
other implementations. other implementations.
""" """
WORDS = [] theWords = set()
def __init__(self): def __init__(self):
NWSpellCheck.__init__(self) NWSpellCheck.__init__(self)
@@ -250,17 +258,19 @@ class NWSpellSimple(NWSpellCheck):
"""Load a dictionary as a list from the app assets folder. """Load a dictionary as a list from the app assets folder.
""" """
self.theLang = theLang self.theLang = theLang
self.WORDS = [] self.theWords = set()
dictFile = os.path.join(self.mainConf.dictPath, theLang+".dict") dictFile = os.path.join(self.mainConf.dictPath, theLang+".dict")
try: try:
with open(dictFile, mode="r", encoding="utf-8") as wordsFile: with open(dictFile, mode="r", encoding="utf-8") as wordsFile:
for theLine in wordsFile: for theLine in wordsFile:
if len(theLine) == 0 or theLine.startswith("#"): if len(theLine) == 0 or theLine.startswith("#"):
continue continue
self.WORDS.append(theLine.strip().lower()) self.theWords.add(theLine.strip().lower())
logger.debug("Spell check word list for language %s loaded" % theLang)
logger.debug("Word list contains %d words" % len(self.WORDS)) logger.debug("Spell check dictionary for language %s loaded" % theLang)
logger.debug("Dictionary contains %d words" % len(self.theWords))
self.spellLanguage = theLang self.spellLanguage = theLang
except Exception: except Exception:
logger.error("Failed to load spell check word list for language %s" % theLang) logger.error("Failed to load spell check word list for language %s" % theLang)
nw.logException() nw.logException()
@@ -268,8 +278,7 @@ class NWSpellSimple(NWSpellCheck):
self._readProjectDictionary(projectDict) self._readProjectDictionary(projectDict)
for pWord in self.projDict: for pWord in self.projDict:
if pWord not in self.WORDS: self.theWords.add(pWord)
self.WORDS.append(pWord)
return return
@@ -279,7 +288,7 @@ class NWSpellSimple(NWSpellCheck):
word by the syntax highlighter. word by the syntax highlighter.
""" """
theWord = theWord.replace(self.mainConf.fmtApostrophe, "'").lower() theWord = theWord.replace(self.mainConf.fmtApostrophe, "'").lower()
return theWord in self.WORDS return theWord in self.theWords
def suggestWords(self, theWord): def suggestWords(self, theWord):
"""Get suggestions for correct word from difflib, and make sure """Get suggestions for correct word from difflib, and make sure
@@ -292,7 +301,7 @@ class NWSpellSimple(NWSpellCheck):
if len(theWord) == 0: if len(theWord) == 0:
return [] return []
theMatches = difflib.get_close_matches(theWord.lower(), self.WORDS, n=10, cutoff=0.75) theMatches = difflib.get_close_matches(theWord.lower(), self.theWords, n=10, cutoff=0.75)
theOptions = [] theOptions = []
for aWord in theMatches: for aWord in theMatches:
if len(aWord) == 0: if len(aWord) == 0:
@@ -308,8 +317,8 @@ class NWSpellSimple(NWSpellCheck):
"""Wrapper for the internal project dictionary feature. """Wrapper for the internal project dictionary feature.
""" """
newWord = newWord.strip().lower() newWord = newWord.strip().lower()
if newWord not in self.WORDS: if newWord not in self.theWords:
self.WORDS.append(newWord) self.theWords.add(newWord)
NWSpellCheck.addWord(self, newWord) NWSpellCheck.addWord(self, newWord)
return return
+2
View File
@@ -20,6 +20,7 @@ from nw.gui.projtree import GuiProjectTree
from nw.gui.projwizard import GuiProjectWizard from nw.gui.projwizard import GuiProjectWizard
from nw.gui.statusbar import GuiMainStatus from nw.gui.statusbar import GuiMainStatus
from nw.gui.theme import GuiTheme from nw.gui.theme import GuiTheme
from nw.gui.wordlist import GuiWordList
from nw.gui.writingstats import GuiWritingStats from nw.gui.writingstats import GuiWritingStats
__all__ = [ __all__ = [
@@ -44,5 +45,6 @@ __all__ = [
"GuiProjectTree", "GuiProjectTree",
"GuiProjectWizard", "GuiProjectWizard",
"GuiTheme", "GuiTheme",
"GuiWordList",
"GuiWritingStats", "GuiWritingStats",
] ]
+9 -4
View File
@@ -910,23 +910,28 @@ class GuiMainMenu(QMenuBar):
# Tools # Tools
self.toolsMenu = self.addMenu("&Tools") self.toolsMenu = self.addMenu("&Tools")
# Tools > Toggle Spell Check # Tools > Check Spelling
self.aSpellCheck = QAction("Check Spelling", self) self.aSpellCheck = QAction("Check Spelling", self)
self.aSpellCheck.setStatusTip("Toggle check spelling") self.aSpellCheck.setStatusTip("Toggle check spelling")
self.aSpellCheck.setCheckable(True) self.aSpellCheck.setCheckable(True)
self.aSpellCheck.setChecked(self.theProject.spellCheck) self.aSpellCheck.setChecked(self.theProject.spellCheck)
# Here we must used triggered, not toggled, to avoid recursion self.aSpellCheck.triggered.connect(self._toggleSpellCheck) # triggered, not toggled!
self.aSpellCheck.triggered.connect(self._toggleSpellCheck)
self.aSpellCheck.setShortcut("Ctrl+F7") self.aSpellCheck.setShortcut("Ctrl+F7")
self.toolsMenu.addAction(self.aSpellCheck) self.toolsMenu.addAction(self.aSpellCheck)
# Tools > Update Spell Check # Tools > Re-Run Spell Check
self.aReRunSpell = QAction("Re-Run Spell Check", self) self.aReRunSpell = QAction("Re-Run Spell Check", self)
self.aReRunSpell.setStatusTip("Run the spell checker on current document") self.aReRunSpell.setStatusTip("Run the spell checker on current document")
self.aReRunSpell.setShortcut("F7") self.aReRunSpell.setShortcut("F7")
self.aReRunSpell.triggered.connect(lambda: self.theParent.docEditor.spellCheckDocument()) self.aReRunSpell.triggered.connect(lambda: self.theParent.docEditor.spellCheckDocument())
self.toolsMenu.addAction(self.aReRunSpell) self.toolsMenu.addAction(self.aReRunSpell)
# Tools > Project Word List
self.aEditWordList = QAction("Project Word List", self)
self.aEditWordList.setStatusTip("Edit the project's word list")
self.aEditWordList.triggered.connect(lambda: self.theParent.showProjectWordListDialog())
self.toolsMenu.addAction(self.aEditWordList)
# Tools > Separator # Tools > Separator
self.toolsMenu.addSeparator() self.toolsMenu.addSeparator()
+213
View File
@@ -0,0 +1,213 @@
# -*- coding: utf-8 -*-
"""
novelWriter GUI User Wordlist
===============================
Class holding the user's wordlist dialog
File History:
Created: 2021-02-12 [1.2b1]
This file is a part of novelWriter
Copyright 20182021, Veronica Berglyd Olsen
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
import nw
import logging
import os
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
QDialog, QDialogButtonBox, QVBoxLayout, QHBoxLayout, QListWidget,
QAbstractItemView, QPushButton, QLineEdit, QLabel
)
from nw.constants import nwFiles, nwAlert
logger = logging.getLogger(__name__)
class GuiWordList(QDialog):
def __init__(self, theParent, theProject):
QDialog.__init__(self, theParent)
logger.debug("Initialising GuiWordList ...")
self.setObjectName("GuiWordList")
self.mainConf = nw.CONFIG
self.theParent = theParent
self.theTheme = theParent.theTheme
self.theProject = theProject
self.optState = theProject.optState
self.setWindowTitle("Project Word List")
mS = self.mainConf.pxInt(250)
wW = self.mainConf.pxInt(320)
wH = self.mainConf.pxInt(340)
self.setMinimumWidth(mS)
self.setMinimumHeight(mS)
self.resize(
self.mainConf.pxInt(self.optState.getInt("GuiWordList", "winWidth", wW)),
self.mainConf.pxInt(self.optState.getInt("GuiWordList", "winHeight", wH))
)
# Main Widgets
# ============
self.headLabel = QLabel("<b>Project Word List</b>")
self.listBox = QListWidget()
self.listBox.setDragDropMode(QAbstractItemView.NoDragDrop)
self.listBox.setSortingEnabled(True)
self.newEntry = QLineEdit()
self.addButton = QPushButton(self.theTheme.getIcon("add"), "")
self.addButton.setToolTip("Add new entry")
self.addButton.clicked.connect(self._doAdd)
self.delButton = QPushButton(self.theTheme.getIcon("remove"), "")
self.delButton.setToolTip("Delete selected entry")
self.delButton.clicked.connect(self._doDelete)
self.editBox = QHBoxLayout()
self.editBox.addWidget(self.newEntry, 1)
self.editBox.addWidget(self.addButton, 0)
self.editBox.addWidget(self.delButton, 0)
self.buttonBox = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Close)
self.buttonBox.accepted.connect(self._doSave)
self.buttonBox.rejected.connect(self._doClose)
# Assemble
# ========
self.outerBox = QVBoxLayout()
self.outerBox.addWidget(self.headLabel)
self.outerBox.addSpacing(self.mainConf.pxInt(8))
self.outerBox.addWidget(self.listBox, 1)
self.outerBox.addLayout(self.editBox, 0)
self.outerBox.addSpacing(self.mainConf.pxInt(12))
self.outerBox.addWidget(self.buttonBox, 0)
self.setLayout(self.outerBox)
self._loadWordList()
logger.debug("GuiWordList initialisation complete")
return
##
# Slots
##
def _doAdd(self):
"""Add a new word to the word list.
"""
newWord = self.newEntry.text().strip()
if newWord == "":
self.theParent.makeAlert("Cannot add a blank word.", nwAlert.ERROR)
return False
if self.listBox.findItems(newWord, Qt.MatchExactly):
self.theParent.makeAlert(
"The word '%s' is already in the word list." % newWord, nwAlert.ERROR
)
return False
self.listBox.addItem(newWord)
self.newEntry.setText("")
return True
def _doDelete(self):
"""Delete the selected item.
"""
selItem = self.listBox.selectedItems()
if selItem:
self.listBox.takeItem(self.listBox.row(selItem[0]))
return
def _doSave(self):
"""Save the new word list and close.
"""
self._saveGuiSettings()
dctFile = os.path.join(self.theProject.projMeta, nwFiles.PROJ_DICT)
tmpFile = dctFile + "~"
try:
with open(tmpFile, mode="w", encoding="utf8") as outFile:
for i in range(self.listBox.count()):
outFile.write(self.listBox.item(i).text() + "\n")
except Exception:
logger.error("Could not save new word list")
nw.logException()
self.reject()
return False
if os.path.isfile(dctFile):
os.unlink(dctFile)
os.rename(tmpFile, dctFile)
self.accept()
return True
def _doClose(self):
"""Close without saving the word list.
"""
self._saveGuiSettings()
self.reject()
return
##
# Internal Functions
##
def _loadWordList(self):
"""Load the project's word list, if it exists.
"""
self.listBox.clear()
wordList = os.path.join(self.theProject.projMeta, nwFiles.PROJ_DICT)
if not os.path.isfile(wordList):
logger.debug("No project dictionary file found")
return False
with open(wordList, mode="r", encoding="utf8") as inFile:
for inLine in inFile:
theWord = inLine.strip()
if len(theWord) == 0:
continue
self.listBox.addItem(theWord)
return True
def _saveGuiSettings(self):
"""Save GUI settings.
"""
winWidth = self.mainConf.rpxInt(self.width())
winHeight = self.mainConf.rpxInt(self.height())
self.optState.setValue("GuiWordList", "winWidth", winWidth)
self.optState.setValue("GuiWordList", "winHeight", winHeight)
return
# END Class GuiWordList
+17 -1
View File
@@ -43,7 +43,7 @@ from nw.gui import (
GuiDocViewDetails, GuiDocViewer, GuiItemDetails, GuiItemEditor, GuiDocViewDetails, GuiDocViewer, GuiItemDetails, GuiItemEditor,
GuiMainMenu, GuiMainStatus, GuiNovelTree, GuiOutline, GuiOutlineDetails, GuiMainMenu, GuiMainStatus, GuiNovelTree, GuiOutline, GuiOutlineDetails,
GuiPreferences, GuiProjectDetails, GuiProjectLoad, GuiProjectSettings, GuiPreferences, GuiProjectDetails, GuiProjectLoad, GuiProjectSettings,
GuiProjectTree, GuiProjectWizard, GuiTheme, GuiWritingStats GuiProjectTree, GuiProjectWizard, GuiTheme, GuiWordList, GuiWritingStats
) )
from nw.core import NWProject, NWDoc, NWIndex from nw.core import NWProject, NWDoc, NWIndex
from nw.constants import nwItemType, nwItemClass, nwAlert, nwLists from nw.constants import nwItemType, nwItemClass, nwAlert, nwLists
@@ -1017,6 +1017,22 @@ class GuiMain(QMainWindow):
return return
def showProjectWordListDialog(self):
"""Open the project word list dialog.
"""
if not self.hasProject:
logger.error("No project open")
return
dlgWords = GuiWordList(self, self.theProject)
dlgWords.exec_()
if dlgWords.result() == QDialog.Accepted:
logger.debug("Reloading word list")
self.docEditor.setDictionaries()
return
def showWritingStatsDialog(self): def showWritingStatsDialog(self):
"""Open the session log dialog. """Open the session log dialog.
""" """
+2 -2
View File
@@ -139,13 +139,13 @@ def testCoreSpell_Simple(monkeypatch, tmpDir, tmpConf):
monkeypatch.setattr("builtins.open", causeOSError) monkeypatch.setattr("builtins.open", causeOSError)
spChk.setLanguage("en", wList) spChk.setLanguage("en", wList)
assert spChk.spellLanguage is None assert spChk.spellLanguage is None
assert spChk.WORDS == spChk.projDict assert spChk.theWords == set(spChk.projDict)
monkeypatch.undo() monkeypatch.undo()
# Load dictionary properly # Load dictionary properly
spChk.setLanguage("en", wList) spChk.setLanguage("en", wList)
assert spChk.projDict == ["a_word", "b_word", "c_word"] assert spChk.projDict == ["a_word", "b_word", "c_word"]
assert spChk.WORDS == ["e_word", "f_word", "g_word", "a_word", "b_word", "c_word"] assert spChk.theWords == {"e_word", "f_word", "g_word", "a_word", "b_word", "c_word"}
# Check words # Check words
assert spChk.checkWord("a_word") assert spChk.checkWord("a_word")
+1 -1
View File
@@ -61,7 +61,7 @@ def testGuiDialogs_Quotes(qtbot, monkeypatch, nwGUI, nwMinimal):
# END Test testDialogs_Quotes # END Test testDialogs_Quotes
@pytest.mark.gui @pytest.mark.gui
def testGuiDialogs_Other(qtbot, monkeypatch, nwGUI, nwMinimal, tmpDir): def testGuiDialogs_Other(qtbot, monkeypatch, nwGUI, tmpDir):
"""Various other dialog tests. """Various other dialog tests.
""" """
monkeypatch.setattr(QFileDialog, "getExistingDirectory", lambda *args, **kwargs: tmpDir) monkeypatch.setattr(QFileDialog, "getExistingDirectory", lambda *args, **kwargs: tmpDir)
+133
View File
@@ -0,0 +1,133 @@
# -*- coding: utf-8 -*-
"""
novelWriter Other Dialog Classes Tester
=========================================
This file is a part of novelWriter
Copyright 20182021, Veronica Berglyd Olsen
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
import os
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QDialog, QMessageBox, QAction
from tools import writeFile, readFile, getGuiItem
from dummy import causeOSError
from nw.gui.wordlist import GuiWordList
from nw.constants import nwFiles
keyDelay = 2
typeDelay = 1
stepDelay = 20
def testGuiWordList_Dialog(qtbot, monkeypatch, nwGUI, nwMinimal, tmpDir):
"""test the word list editor.
"""
monkeypatch.setattr(QMessageBox, "question", lambda *args: QMessageBox.Yes)
monkeypatch.setattr(QMessageBox, "critical", lambda *args: QMessageBox.Yes)
monkeypatch.setattr(GuiWordList, "exec_", lambda *args: None)
monkeypatch.setattr(GuiWordList, "result", lambda *args: QDialog.Accepted)
monkeypatch.setattr(GuiWordList, "accept", lambda *args: None)
# Open project
nwGUI.openProject(nwMinimal)
qtbot.wait(stepDelay)
dictFile = os.path.join(nwMinimal, "meta", nwFiles.PROJ_DICT)
# Load the dialog
nwGUI.mainMenu.aEditWordList.activate(QAction.Trigger)
qtbot.waitUntil(lambda: getGuiItem("GuiWordList") is not None, timeout=1000)
wList = getGuiItem("GuiWordList")
assert isinstance(wList, GuiWordList)
wList.show()
qtbot.wait(stepDelay)
# List should be blank
assert wList.listBox.count() == 0
# Add words
writeFile(dictFile, (
"word_a\n"
"word_c\n"
"word_g\n"
" \n" # Should be ignored
"word_f\n"
"word_b\n"
))
qtbot.wait(stepDelay)
assert wList._loadWordList()
# Check that the content was loaded
assert wList.listBox.item(0).text() == "word_a"
assert wList.listBox.item(1).text() == "word_b"
assert wList.listBox.item(2).text() == "word_c"
assert wList.listBox.item(3).text() == "word_f"
assert wList.listBox.item(4).text() == "word_g"
# Add a blank word
wList.newEntry.setText(" ")
assert not wList._doAdd()
# Add an existing word
wList.newEntry.setText("word_c")
assert not wList._doAdd()
# Add a new word
wList.newEntry.setText("word_d")
assert wList._doAdd()
# Check that the content now
assert wList.listBox.item(0).text() == "word_a"
assert wList.listBox.item(1).text() == "word_b"
assert wList.listBox.item(2).text() == "word_c"
assert wList.listBox.item(3).text() == "word_d"
assert wList.listBox.item(4).text() == "word_f"
assert wList.listBox.item(5).text() == "word_g"
# Delete a word
wList.newEntry.setText("delete_me")
assert wList._doAdd()
assert wList.listBox.item(0).text() == "delete_me"
delItem = wList.listBox.findItems("delete_me", Qt.MatchExactly)[0]
assert delItem.text() == "delete_me"
delItem.setSelected(True)
wList._doDelete()
assert wList.listBox.findItems("delete_me", Qt.MatchExactly) == []
assert wList.listBox.item(0).text() == "word_a"
# Save files
assert wList._doSave()
assert readFile(dictFile) == (
"word_a\n"
"word_b\n"
"word_c\n"
"word_d\n"
"word_f\n"
"word_g\n"
)
# Save again and make it fail
monkeypatch.setattr("builtins.open", causeOSError)
assert not wList._doSave()
# qtbot.stopForInteraction()
wList._doClose()
# END Test testGuiWordList_Dialog