diff --git a/README.md b/README.md
index 488d39be..a884f462 100644
--- a/README.md
+++ b/README.md
@@ -121,8 +121,8 @@ to quickly navigate between the documents while editing.
# Installing and Running
-For install instructions, please check the [documentation](https://novelwriter.readthedocs.io/) in
-the [Getting Started](https://novelwriter.readthedocs.io/en/latest/int_started.html) section.
+For install instructions, please check the [Getting Started](https://novelwriter.readthedocs.io/en/latest/int_started.html) section in
+the [documentation](https://novelwriter.readthedocs.io/).
## TLDR Instructions
diff --git a/nw/core/options.py b/nw/core/options.py
index 52a30af5..51fba3b6 100644
--- a/nw/core/options.py
+++ b/nw/core/options.py
@@ -104,6 +104,10 @@ class OptionState():
"countFrom",
"clearDouble",
},
+ "GuiWordList": {
+ "winWidth",
+ "winHeight",
+ }
}
return
diff --git a/nw/core/spellcheck.py b/nw/core/spellcheck.py
index 8dd33bbe..9d22afcd 100644
--- a/nw/core/spellcheck.py
+++ b/nw/core/spellcheck.py
@@ -123,6 +123,7 @@ class NWSpellCheck():
if len(theLine) > 0 and theLine not in self.projDict:
self.projDict.append(theLine)
logger.debug("Project word list contains %d words" % len(self.projDict))
+
except Exception:
logger.error("Failed to load project word list")
nw.logException()
@@ -141,6 +142,7 @@ class NWSpellEnchant(NWSpellCheck):
def __init__(self):
NWSpellCheck.__init__(self)
logger.debug("Enchant spell checking activated")
+ self.theBroker = None
return
def setLanguage(self, theLang, projectDict=None):
@@ -150,9 +152,15 @@ class NWSpellEnchant(NWSpellCheck):
"""
try:
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
logger.debug("Enchant spell checking for language %s loaded" % theLang)
+
except Exception:
logger.error("Failed to load enchant spell checking for language %s" % theLang)
self.theDict = NWSpellEnchantDummy()
@@ -238,7 +246,7 @@ class NWSpellSimple(NWSpellCheck):
when no other is available. This method is fairly slow compared to
other implementations.
"""
- WORDS = []
+ theWords = set()
def __init__(self):
NWSpellCheck.__init__(self)
@@ -250,17 +258,19 @@ class NWSpellSimple(NWSpellCheck):
"""Load a dictionary as a list from the app assets folder.
"""
self.theLang = theLang
- self.WORDS = []
+ self.theWords = set()
dictFile = os.path.join(self.mainConf.dictPath, theLang+".dict")
try:
with open(dictFile, mode="r", encoding="utf-8") as wordsFile:
for theLine in wordsFile:
if len(theLine) == 0 or theLine.startswith("#"):
continue
- self.WORDS.append(theLine.strip().lower())
- logger.debug("Spell check word list for language %s loaded" % theLang)
- logger.debug("Word list contains %d words" % len(self.WORDS))
+ self.theWords.add(theLine.strip().lower())
+
+ logger.debug("Spell check dictionary for language %s loaded" % theLang)
+ logger.debug("Dictionary contains %d words" % len(self.theWords))
self.spellLanguage = theLang
+
except Exception:
logger.error("Failed to load spell check word list for language %s" % theLang)
nw.logException()
@@ -268,8 +278,7 @@ class NWSpellSimple(NWSpellCheck):
self._readProjectDictionary(projectDict)
for pWord in self.projDict:
- if pWord not in self.WORDS:
- self.WORDS.append(pWord)
+ self.theWords.add(pWord)
return
@@ -279,7 +288,7 @@ class NWSpellSimple(NWSpellCheck):
word by the syntax highlighter.
"""
theWord = theWord.replace(self.mainConf.fmtApostrophe, "'").lower()
- return theWord in self.WORDS
+ return theWord in self.theWords
def suggestWords(self, theWord):
"""Get suggestions for correct word from difflib, and make sure
@@ -292,7 +301,7 @@ class NWSpellSimple(NWSpellCheck):
if len(theWord) == 0:
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 = []
for aWord in theMatches:
if len(aWord) == 0:
@@ -308,8 +317,8 @@ class NWSpellSimple(NWSpellCheck):
"""Wrapper for the internal project dictionary feature.
"""
newWord = newWord.strip().lower()
- if newWord not in self.WORDS:
- self.WORDS.append(newWord)
+ if newWord not in self.theWords:
+ self.theWords.add(newWord)
NWSpellCheck.addWord(self, newWord)
return
diff --git a/nw/gui/__init__.py b/nw/gui/__init__.py
index aa7455b5..8744348f 100644
--- a/nw/gui/__init__.py
+++ b/nw/gui/__init__.py
@@ -20,6 +20,7 @@ from nw.gui.projtree import GuiProjectTree
from nw.gui.projwizard import GuiProjectWizard
from nw.gui.statusbar import GuiMainStatus
from nw.gui.theme import GuiTheme
+from nw.gui.wordlist import GuiWordList
from nw.gui.writingstats import GuiWritingStats
__all__ = [
@@ -44,5 +45,6 @@ __all__ = [
"GuiProjectTree",
"GuiProjectWizard",
"GuiTheme",
+ "GuiWordList",
"GuiWritingStats",
]
diff --git a/nw/gui/mainmenu.py b/nw/gui/mainmenu.py
index 4cf4960a..6d6267a9 100644
--- a/nw/gui/mainmenu.py
+++ b/nw/gui/mainmenu.py
@@ -910,23 +910,28 @@ class GuiMainMenu(QMenuBar):
# Tools
self.toolsMenu = self.addMenu("&Tools")
- # Tools > Toggle Spell Check
+ # Tools > Check Spelling
self.aSpellCheck = QAction("Check Spelling", self)
self.aSpellCheck.setStatusTip("Toggle check spelling")
self.aSpellCheck.setCheckable(True)
self.aSpellCheck.setChecked(self.theProject.spellCheck)
- # Here we must used triggered, not toggled, to avoid recursion
- self.aSpellCheck.triggered.connect(self._toggleSpellCheck)
+ self.aSpellCheck.triggered.connect(self._toggleSpellCheck) # triggered, not toggled!
self.aSpellCheck.setShortcut("Ctrl+F7")
self.toolsMenu.addAction(self.aSpellCheck)
- # Tools > Update Spell Check
+ # Tools > Re-Run Spell Check
self.aReRunSpell = QAction("Re-Run Spell Check", self)
self.aReRunSpell.setStatusTip("Run the spell checker on current document")
self.aReRunSpell.setShortcut("F7")
self.aReRunSpell.triggered.connect(lambda: self.theParent.docEditor.spellCheckDocument())
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
self.toolsMenu.addSeparator()
diff --git a/nw/gui/wordlist.py b/nw/gui/wordlist.py
new file mode 100644
index 00000000..f5b6fe80
--- /dev/null
+++ b/nw/gui/wordlist.py
@@ -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 2018–2021, 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 .
+"""
+
+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("Project Word List")
+
+ 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
diff --git a/nw/guimain.py b/nw/guimain.py
index cb2756cc..6cb68cb3 100644
--- a/nw/guimain.py
+++ b/nw/guimain.py
@@ -43,7 +43,7 @@ from nw.gui import (
GuiDocViewDetails, GuiDocViewer, GuiItemDetails, GuiItemEditor,
GuiMainMenu, GuiMainStatus, GuiNovelTree, GuiOutline, GuiOutlineDetails,
GuiPreferences, GuiProjectDetails, GuiProjectLoad, GuiProjectSettings,
- GuiProjectTree, GuiProjectWizard, GuiTheme, GuiWritingStats
+ GuiProjectTree, GuiProjectWizard, GuiTheme, GuiWordList, GuiWritingStats
)
from nw.core import NWProject, NWDoc, NWIndex
from nw.constants import nwItemType, nwItemClass, nwAlert, nwLists
@@ -1017,6 +1017,22 @@ class GuiMain(QMainWindow):
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):
"""Open the session log dialog.
"""
diff --git a/tests/test_core/test_core_spell.py b/tests/test_core/test_core_spell.py
index 4a9bde15..46ee1937 100644
--- a/tests/test_core/test_core_spell.py
+++ b/tests/test_core/test_core_spell.py
@@ -139,13 +139,13 @@ def testCoreSpell_Simple(monkeypatch, tmpDir, tmpConf):
monkeypatch.setattr("builtins.open", causeOSError)
spChk.setLanguage("en", wList)
assert spChk.spellLanguage is None
- assert spChk.WORDS == spChk.projDict
+ assert spChk.theWords == set(spChk.projDict)
monkeypatch.undo()
# Load dictionary properly
spChk.setLanguage("en", wList)
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
assert spChk.checkWord("a_word")
diff --git a/tests/test_gui/test_gui_dialogs.py b/tests/test_gui/test_gui_dialogs.py
index 243434d3..f4f6f27d 100644
--- a/tests/test_gui/test_gui_dialogs.py
+++ b/tests/test_gui/test_gui_dialogs.py
@@ -61,7 +61,7 @@ def testGuiDialogs_Quotes(qtbot, monkeypatch, nwGUI, nwMinimal):
# END Test testDialogs_Quotes
@pytest.mark.gui
-def testGuiDialogs_Other(qtbot, monkeypatch, nwGUI, nwMinimal, tmpDir):
+def testGuiDialogs_Other(qtbot, monkeypatch, nwGUI, tmpDir):
"""Various other dialog tests.
"""
monkeypatch.setattr(QFileDialog, "getExistingDirectory", lambda *args, **kwargs: tmpDir)
diff --git a/tests/test_gui/test_gui_wordlist.py b/tests/test_gui/test_gui_wordlist.py
new file mode 100644
index 00000000..fdbc1725
--- /dev/null
+++ b/tests/test_gui/test_gui_wordlist.py
@@ -0,0 +1,133 @@
+# -*- coding: utf-8 -*-
+"""
+novelWriter – Other Dialog Classes Tester
+=========================================
+
+This file is a part of novelWriter
+Copyright 2018–2021, 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 .
+"""
+
+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