diff --git a/python/box.py b/python/box.py
index 0c55a08ebfdfd5338b659d628f76be52dfd64093..0d924d18359e9a4ae6a803e3f4c0a3ae47ed731d 100644
--- a/python/box.py
+++ b/python/box.py
@@ -2,7 +2,6 @@ from PySide6.QtCore import QPointF, QRectF, Qt
 from PySide6.QtGui import QPainterPath
 from PySide6.QtGui import QFont
 from PySide6.QtGui import QCursor
-from PySide6.QtGui import QAction
 from PySide6.QtWidgets import QGraphicsItem
 from PySide6.QtWidgets import QGraphicsSceneContextMenuEvent
 from PySide6.QtWidgets import QMenu
diff --git a/python/code_editor_menu.py b/python/code_editor_settings_menu.py
similarity index 58%
rename from python/code_editor_menu.py
rename to python/code_editor_settings_menu.py
index 4859d28d213728b99a94e0bd48e739b75c4688b7..1105ed9d59b3786a3acd0790dce9ceef42cfedb5 100644
--- a/python/code_editor_menu.py
+++ b/python/code_editor_settings_menu.py
@@ -2,25 +2,27 @@ from PySide6.QtCore import Slot # for signal/slot support
 from PySide6.QtWidgets import QMenu
 
 """
-Provides a code editor menu.
+Provides a settings menu for code editor settings.
 """
 
-def make_code_editor_menu(preferences_manager, spellcheck_manager):
+def make_code_editor_settings_menu(gui_manager):
+    preferences_manager = gui_manager.preferences_manager
+    mp_code_column = gui_manager.mp_code_column
+    spellcheck_dialog = gui_manager.spellcheck_whitelist_dialog_wrapper
+
     @Slot()
     def _about_to_show():
         # split screen
-        is_using_code_editor_split_screen = preferences_manager \
-                  .gui_manager.mp_code_column.using_code_editor_split_screen()
         preferences_manager.action_use_code_editor_split_screen.setChecked(
-                         is_using_code_editor_split_screen)
+                         mp_code_column.is_using_code_editor_split_screen())
 
-        # spellchecker whitelist
-        spellcheck_manager.action_spellchecker_whitelist.setDisabled(
-                                    spellcheck_manager.dialog.isVisible())
+        # spellchecker whitelist dialog
+        spellcheck_dialog.action_open_spellcheck_whitelist_dialog.setDisabled(
+                      spellcheck_dialog.isVisible())
 
     menu = QMenu("&Code editor")
     menu.addAction(preferences_manager.action_use_spellchecker)
-    menu.addAction(spellcheck_manager.action_spellchecker_whitelist)
+    menu.addAction(spellcheck_dialog.action_open_spellcheck_whitelist_dialog)
     menu.addSeparator()
     line_mode_menu = QMenu("Line mode", menu)
     menu.addMenu(line_mode_menu)
diff --git a/python/mp_code_edit_menu.py b/python/edit_menu.py
similarity index 86%
rename from python/mp_code_edit_menu.py
rename to python/edit_menu.py
index e0a51b8de785a5371b69c9c94b05fad58ae52fa5..8c746659fff115f2809ed895af743ec0bc563032 100644
--- a/python/mp_code_edit_menu.py
+++ b/python/edit_menu.py
@@ -1,5 +1,4 @@
 from PySide6.QtWidgets import QMenu
-from PySide6.QtGui import QAction
 from PySide6.QtCore import Slot
 
 """
@@ -9,7 +8,7 @@ Menus for MP code view 1 are used unless view 2 has focus and split-screen
 is active.
 """
 
-class MPCodeEditMenu(QMenu):
+class EditMenu(QMenu):
     def __init__(self, gui_manager):
         super().__init__("&Edit")
         self.mp_code_column = gui_manager.mp_code_column
@@ -22,7 +21,7 @@ class MPCodeEditMenu(QMenu):
     def _set_menu_visibility(self):
         self.clear()
         if self.mp_code_view_2.hasFocus() \
-                     and self.mp_code_column.using_code_editor_split_screen():
+                   and self.mp_code_column.is_using_code_editor_split_screen():
             menu = self.mp_code_view_2.mp_code_view_menu()
         else:
             menu = self.mp_code_view_1.mp_code_view_menu()
diff --git a/python/graph_list_selection_model.py b/python/graph_list_selection_model.py
index 292da31321172b10b799cc9f697183d35f3c8249..d3abf15dd863edb5ad2e5c1d57b1128fc2c44b32 100644
--- a/python/graph_list_selection_model.py
+++ b/python/graph_list_selection_model.py
@@ -1,6 +1,4 @@
 from PySide6.QtCore import QObject # for signal/slot support
-from PySide6.QtCore import Signal
-from PySide6.QtCore import Slot
 from PySide6.QtCore import Qt
 from PySide6.QtCore import QModelIndex
 from PySide6.QtCore import QItemSelectionModel
diff --git a/python/graph_list_table_model.py b/python/graph_list_table_model.py
index a78fb0a394902356dc56bcf91eba7575dbab0516..6d1c40b98804a50c0ff23ad9e6fb5784410cd710 100644
--- a/python/graph_list_table_model.py
+++ b/python/graph_list_table_model.py
@@ -1,5 +1,3 @@
-from PySide6.QtCore import Signal # for signal/slot support
-from PySide6.QtCore import Slot # for signal/slot support
 from PySide6.QtCore import Qt, QAbstractTableModel, QModelIndex
 from graph_item import GraphItem
 from settings import settings
diff --git a/python/graph_list_view.py b/python/graph_list_view.py
index 2be81077a6150fc868149df057179c04f43dda0c..91432ac8484b20db45e5462c8b47c3d9c0b3cd97 100644
--- a/python/graph_list_view.py
+++ b/python/graph_list_view.py
@@ -1,5 +1,4 @@
 from PySide6.QtCore import QObject # for signal/slot support
-from PySide6.QtCore import Signal
 from PySide6.QtCore import Slot
 from PySide6.QtCore import Qt
 from PySide6.QtCore import QModelIndex
diff --git a/python/gui_manager.py b/python/gui_manager.py
index 83b85e3c400a3a9366dc25bd03ebd6498df3c3f9..7b5bcd6e66b726b389567f202d05c372cc96d29b 100644
--- a/python/gui_manager.py
+++ b/python/gui_manager.py
@@ -35,8 +35,8 @@ from graph_list_selection_model import GraphListSelectionModel
 from mp_code_manager import MPCodeManager
 from mp_code_view import MPCodeView
 from mp_code_change_tracker import MPCodeChangeTracker
-from mp_code_edit_menu import MPCodeEditMenu
-from code_editor_menu import make_code_editor_menu
+from edit_menu import EditMenu
+from code_editor_settings_menu import make_code_editor_settings_menu
 from gry_manager import GryManager
 from settings_themes_menu import SettingsThemesMenu
 from socket_client_endpoint_menu import SocketClientEndpointMenu
@@ -52,7 +52,8 @@ from preferences import preferences
 from preferences_manager import PreferencesManager
 from settings import settings
 from settings_manager import SettingsManager
-from spellcheck_manager import SpellcheckManager
+from spellcheck_whitelist_manager import SpellcheckWhitelistManager
+from spellcheck_whitelist_dialog_wrapper import SpellcheckWhitelistDialogWrapper
 from session_persistence import SessionPersistence
 from session_snapshots import SessionSnapshots
 from session_snapshots_dialog_wrapper import snapshot_files
@@ -112,7 +113,12 @@ class GUIManager(QObject):
         self.session_snapshots = SessionSnapshots(self)
 
         # spellcheck manager
-        self.spellcheck_manager = SpellcheckManager(self.w)
+        self.spellcheck_whitelist_manager = SpellcheckWhitelistManager()
+
+        # spellcheck whitelist dialog wrapper
+        self.spellcheck_whitelist_dialog_wrapper = \
+              SpellcheckWhitelistDialogWrapper(self.w,
+                                         self.spellcheck_whitelist_manager)
 
         # the graph list table model
         self.graph_list_table_model = GraphListTableModel()
@@ -187,21 +193,21 @@ class GUIManager(QObject):
                                         self.response_compile_mp_code)
         # the MP Code manager
         self.mp_code_manager = MPCodeManager(
-                           self.preferences_manager.signal_preferences_changed,
-                           self.settings_manager.signal_settings_changed,
-                           self.spellcheck_manager.signal_spellcheck_changed,
-                           self.statusbar)
+                  self.preferences_manager.signal_preferences_changed,
+                  self.settings_manager.signal_settings_changed,
+                  self.spellcheck_whitelist_manager.signal_spellcheck_changed,
+                  self.statusbar)
 
         # the MP Code change tracker
         self.mp_code_change_tracker = MPCodeChangeTracker(self)
 
         # persistent dialogs
         self.search_mp_files_dialog_wrapper = SearchMPFilesDialogWrapper(
-                     self.w,
-                     self.preferences_manager.signal_preferences_changed,
-                     self.settings_manager.signal_settings_changed,
-                     self.spellcheck_manager.signal_spellcheck_changed,
-                     self.open_mp_code)
+                  self.w,
+                  self.preferences_manager.signal_preferences_changed,
+                  self.settings_manager.signal_settings_changed,
+                  self.spellcheck_whitelist_manager.signal_spellcheck_changed,
+                  self.open_mp_code)
         self.model_statistics_dialog = ModelStatisticsDialog(
                          self.w, self.graphs_manager,
                          self.mp_code_manager.signal_mp_code_loaded,
@@ -210,13 +216,15 @@ class GUIManager(QObject):
         # the two MP Code editor view windows
         self.mp_code_view_1 = MPCodeView(self.mp_code_manager,
                    self.preferences_manager,
-                   self.spellcheck_manager,
+                   self.spellcheck_whitelist_manager,
+                   self.spellcheck_whitelist_dialog_wrapper,
                    self.search_mp_files_dialog_wrapper.action_search_mp_files,
                    self.application,
                    1)
         self.mp_code_view_2 = MPCodeView(self.mp_code_manager,
                    self.preferences_manager,
-                   self.spellcheck_manager,
+                   self.spellcheck_whitelist_manager,
+                   self.spellcheck_whitelist_dialog_wrapper,
                    self.search_mp_files_dialog_wrapper.action_search_mp_files,
                    self.application,
                    2)
@@ -519,7 +527,7 @@ class GUIManager(QObject):
         self.file_menu.addAction(self.action_exit)
 
         # menu | edit
-        self.mp_code_edit_menu = menubar.addMenu(MPCodeEditMenu(self))
+        self.edit_menu = menubar.addMenu(EditMenu(self))
 
         # menu | actions
         actions_menu = menubar.addMenu('&Actions')
@@ -554,9 +562,8 @@ class GUIManager(QObject):
         self.settings_menu.addMenu(self.settings_themes_menu)
 
         # menu | settings | code editor preferences
-        self._code_editor_menu = make_code_editor_menu(
-                          self.preferences_manager, self.spellcheck_manager)
-        self.settings_menu.addMenu(self._code_editor_menu)
+        self._code_editor_settings_menu = make_code_editor_settings_menu(self)
+        self.settings_menu.addMenu(self._code_editor_settings_menu)
 
         # menu | settings | graph pane preferences
         self.settings_menu.addMenu(self.graph_pane_menu)
diff --git a/python/main_graph_scene.py b/python/main_graph_scene.py
index 7710e0a453969be52b25cbf2dd57f2d963cc437d..dac43b0fd45328977931d190cb21ac54fc4fb297 100644
--- a/python/main_graph_scene.py
+++ b/python/main_graph_scene.py
@@ -2,7 +2,6 @@
 
 import math
 from PySide6.QtCore import QRectF, Qt
-from PySide6.QtCore import Signal
 from PySide6.QtCore import Slot
 from PySide6.QtCore import QObject
 from PySide6.QtCore import QModelIndex
diff --git a/python/mp_code_column.py b/python/mp_code_column.py
index e8c4b2417990f6af87b38a8b389743e97369cbd3..f64100684170335330504749aa0ba3022bd923ff 100644
--- a/python/mp_code_column.py
+++ b/python/mp_code_column.py
@@ -44,6 +44,6 @@ class MPCodeColumn(QWidget):
             b = 0
         self.splitter.setSizes((a, b, sizes[2]))
 
-    def using_code_editor_split_screen(self):
+    def is_using_code_editor_split_screen(self):
         return bool(self.splitter.sizes()[1])
 
diff --git a/python/mp_code_syntax_highlighter.py b/python/mp_code_syntax_highlighter.py
index 29afb74152e7faa224194ff01a5b30198a2dba38..8617cb3e190c37dc4fa2e580fe2ee7427ea70cf5 100644
--- a/python/mp_code_syntax_highlighter.py
+++ b/python/mp_code_syntax_highlighter.py
@@ -10,7 +10,7 @@ from mp_code_expressions import ATOMIC_NAME_EXPRESSION, \
                OPERATOR_EXPRESSION, NUMBER_EXPRESSION, \
                VARIABLE_EXPRESSION, QUOTED_TEXT_EXPRESSION
 from mp_code_event_dict import mp_code_event_dict
-from spellcheck import unknown_words
+from spellcheck import unknown_words_regex
 
 """
 Optimization:
@@ -87,11 +87,7 @@ class MPCodeSyntaxHighlighter(QSyntaxHighlighter):
 
         # create the regex for misspelled words
         if preferences["use_spellchecker"]:
-            unknown_word_set = unknown_words(mp_code_text)
-            misspell_text = '|'.join(unknown_word_set)
-            self.misspell_expression = QRegularExpression(
-                 r"(?<=[^a-zA-Z0-9']|^)(%s)(?=[^a-zA-Z0-9']|$)"%misspell_text,
-                 options=QRegularExpression.CaseInsensitiveOption)
+            self.misspell_expression = unknown_words_regex(mp_code_text)
  
         # perform the rehighlighting
         super().rehighlight()
diff --git a/python/mp_code_view.py b/python/mp_code_view.py
index 3d87dc6be406ec4dfc2fb3d509c64d62732cd395..bb6452d3b6f30b1d26e40926b3e8f305bbe9a525 100644
--- a/python/mp_code_view.py
+++ b/python/mp_code_view.py
@@ -3,6 +3,7 @@
 # http://doc.qt.io/qt-5/qtwidgets-widgets-codeeditor-example.html
 
 from os.path import join, split
+import re
 from PySide6.QtWidgets import QWidget
 from PySide6.QtWidgets import QPlainTextEdit
 from PySide6.QtWidgets import QCompleter
@@ -29,6 +30,7 @@ from find_and_replace_dialog_wrapper import FindAndReplaceDialogWrapper
 from mp_json_io_manager import save_mp_code_file
 from preferences import preferences
 from mp_file_dialog import get_save_filename
+from spellcheck import is_unknown_word, candidate_words
 from message_popup import message_popup
 from mp_logger import log
 
@@ -53,8 +55,10 @@ class MPCodeView(QPlainTextEdit):
     def _move_cursor_to_top(self):
         self.moveCursor(QTextCursor.MoveOperation.Start)
 
-    def __init__(self, mp_code_manager, preferences_manager, spellcheck_manager,
-                 action_search_mp_files, application, instance_number):
+    def __init__(self, mp_code_manager, preferences_manager,
+                 spellcheck_whitelist_manager,
+                 spellcheck_whitelist_dialog_wrapper, action_search_mp_files,
+                 application, instance_number):
         super().__init__()
 
         # the application instance to submit remapped Ctrl+Shift+Z redo to
@@ -66,7 +70,11 @@ class MPCodeView(QPlainTextEdit):
         self.setDocument(self.mp_code_manager.document)
 
         # the spellcheck manager
-        self.spellcheck_manager = spellcheck_manager
+        self.spellcheck_whitelist_manager = spellcheck_whitelist_manager
+
+        # the spellcheck whitelist dialog wrapper
+        self.spellcheck_whitelist_dialog_wrapper \
+                                   = spellcheck_whitelist_dialog_wrapper
 
         # the find and replace dialog wrapper
         self.find_and_replace_dialog_wrapper \
@@ -166,6 +174,31 @@ class MPCodeView(QPlainTextEdit):
                 # great, exported.
                 log("Selection saved as %s"%mp_code_filename)
 
+    # return word to spellcheck else "" if not on a spellcheckable word
+    def _spellcheckable_word(self):
+        # the selected word
+        selected_word = self.textCursor().selectedText()
+
+        # require an alphabetical selection at least 3 characters long
+        if not re.fullmatch(r'[A-Za-z]{3,}', selected_word):
+            return ""
+
+        # require alphabetical boundary around selection
+        anchor = self.textCursor().anchor()
+        if anchor > 0:
+            
+            char_left = self.document().characterAt(anchor - 1)
+            if re.fullmatch(r'[A-Za-z]', char_left):
+                return ""
+        position = self.textCursor().position()
+        if position < self.document().characterCount() - 1:
+            char_right = self.document().characterAt(position)
+            if re.fullmatch(r'[A-Za-z]', char_right):
+                return ""
+
+        # return the valid spellcheckable word
+        return selected_word
+
     # standard menu plus custom items
     # Note: we do not keep this menu because we do not have hooks for
     # built-in items such as "Copy" to keep their selection state fresh.
@@ -181,9 +214,45 @@ class MPCodeView(QPlainTextEdit):
         self.action_save_selection.setDisabled(
                                       not self.textCursor().hasSelection())
         menu.addSeparator()
-        menu.addAction(self.spellcheck_manager.action_spellchecker_whitelist)
-        self.spellcheck_manager.action_spellchecker_whitelist.setDisabled(
-                                  self.spellcheck_manager.dialog.isVisible())
+        menu.addAction(self.spellcheck_whitelist_dialog_wrapper
+                                  .action_open_spellcheck_whitelist_dialog)
+        self.spellcheck_whitelist_dialog_wrapper \
+                     .action_open_spellcheck_whitelist_dialog.setDisabled(
+                         self.spellcheck_whitelist_dialog_wrapper.isVisible())
+
+        # maybe append candidate words and ability to whitelist to the menu
+        cursor_word = self._spellcheckable_word()
+        if cursor_word and is_unknown_word(cursor_word):
+            unknown_word = cursor_word
+            candidates = candidate_words(cursor_word)
+        else:
+            unknown_word, candidates = None, None
+
+        if unknown_word:
+            menu.addSeparator()
+
+            # add each candidate
+            def insert_candidate_function_function(candidate):
+                @Slot()
+                def insert_candidate_function():
+                    self.textCursor().insertText(candidate)
+                return insert_candidate_function
+
+            for candidate in candidates[:10]: # not too many
+                action = QAction(candidate, menu)
+                action.triggered.connect(insert_candidate_function_function(
+                                                              candidate))
+                menu.addAction(action)
+
+            # offer to add to whitelist
+            def add_to_whitelist():
+                self.spellcheck_whitelist_manager.add_user_whitelist_word(
+                                                           unknown_word)
+            action = QAction('Add "%s" to whitelist'%unknown_word, menu)
+            action.setToolTip("Add the selected word to the whitelist")
+            action.triggered.connect(add_to_whitelist)
+            menu.addAction(action)
+
         return menu
 
     # custom menu at the cursor on the editor screen
diff --git a/python/paths_gryphon.py b/python/paths_gryphon.py
index f4b609a0ae4f4aba96d84a0a86f3c6950799305c..ed90b5858bb6ee96493da887ce4912f65febd4c6 100644
--- a/python/paths_gryphon.py
+++ b/python/paths_gryphon.py
@@ -3,13 +3,13 @@ from os.path import expanduser, join
 
 # Gryphon paths
 user_mp_path = join(expanduser("~"), ".gryphon")
-USER_PREFERENCES_FILENAME = join(user_mp_path, "preference_settings")
-SPELLCHECK_WHITELIST_FILENAME = join(user_mp_path, "spellcheck_whitelist")
+USER_PREFERENCES_FILENAME = join(user_mp_path, "preference_settings.json")
+SPELLCHECK_WHITELIST_FILENAME = join(user_mp_path, "spellcheck_whitelist.txt")
 PREVIOUS_SESSION_FILENAME = join(user_mp_path, "previous_session.mp")
 DEFAULT_SETTINGS_FILENAME = join(user_mp_path, "default_theme_settings.stg")
 SNAPSHOTS_PATH = join(user_mp_path, "snapshots")
-TRACE_GENERATOR_PATH_FILENAME = join(user_mp_path, "trace_generator_path")
-EXAMPLES_PATH_FILENAME = join(user_mp_path, "preloaded_examples_path")
+TRACE_GENERATOR_PATH_FILENAME = join(user_mp_path, "trace_generator_path.json")
+EXAMPLES_PATH_FILENAME = join(user_mp_path, "preloaded_examples_path.json")
 RIGAL_SCRATCH = join(user_mp_path, "mp_gryphon_rigal_scratch")
 makedirs(RIGAL_SCRATCH, exist_ok=True) # make requisite .gryphon paths
 makedirs(SNAPSHOTS_PATH, exist_ok=True) # make requisite .gryphon paths
diff --git a/python/preferences_manager.py b/python/preferences_manager.py
index 0046a51a03658627ed130a0e041433cfea3aaf12..54e762fd8b6f0fb72080d75dc1fa65fbe9e0f3bc 100644
--- a/python/preferences_manager.py
+++ b/python/preferences_manager.py
@@ -210,7 +210,7 @@ class PreferencesManager(QObject):
         # split screen: no, see code_editor_menu
         self.action_use_code_editor_split_screen.setChecked(
                                 self.gui_manager.mp_code_column \
-                                .using_code_editor_split_screen())
+                                .is_using_code_editor_split_screen())
 
         # scroll
         scroll_mode = preferences["scroll_mode"]
diff --git a/python/search_mp_files_dialog_wrapper.py b/python/search_mp_files_dialog_wrapper.py
index 188de24837fa5e01120be5b837cd56dc46fa51c0..e6ffd1cb9b7624848cf2d4d154ab57b6ecd1b15f 100644
--- a/python/search_mp_files_dialog_wrapper.py
+++ b/python/search_mp_files_dialog_wrapper.py
@@ -5,7 +5,7 @@ from PySide6.QtCore import Qt
 from PySide6.QtCore import Slot # for signal/slot support
 from PySide6.QtCore import QAbstractTableModel, QModelIndex, QSortFilterProxyModel
 from PySide6.QtGui import QAction
-from PySide6.QtGui import QTextDocument, QTextCursor, QIcon
+from PySide6.QtGui import QIcon
 from PySide6.QtGui import QClipboard
 from PySide6.QtWidgets import QDialog, QComboBox, QAbstractItemView
 from PySide6.QtWidgets import QTableView
diff --git a/python/spellcheck.py b/python/spellcheck.py
index e4f8a18c91ea5d99669ae6a4094e3d8e1d1b3dff..7afbe8fb34c9eb52eeeee0cd47452e234b2ac8dc 100644
--- a/python/spellcheck.py
+++ b/python/spellcheck.py
@@ -1,40 +1,59 @@
 from os.path import isfile
 import re
 from spellchecker import SpellChecker
-from PySide6.QtCore import QFile, QTextStream, QIODevice
+from PySide6.QtCore import QFile, QTextStream, QIODevice, QRegularExpression
 from paths_gryphon import SPELLCHECK_WHITELIST_FILENAME
 from mp_code_expressions import MP_KEYWORDS, MP_META_SYMBOLS
-"""
-Note: this code uses Python module pyspellchecker which uses a Levenshtein
-Distance algorithm.  It is not complete but does fairly well.  We remove
-all non-ASCII unicode, so words with character accents are not managed.
-Whitelist words come from pyspellchecker's dictionary, MP keywords and
-metasymbols, Gryphon's whitelist, and the user-defined whitelist.
 
-This code requires the pyspellchecker module.
-To install use: pip install pyspellchecker.
+"""
+spellcheck provides singletone global interfaces.
 
 Interfaces:
-  * unknown_words(mp_code_text) - Returns set of unknown words.
-  * user_whitelist() - Returns the user's active whitelist text.
-  * change_user_whitelist(text) - Saves the user's whitelist text and
-    replaces the user whitelist with the new one.
+  * set_checker(whitelist_text) - set the spellchecker given the user's text
+  * user_whitelist() - returns _user_whitelist
+  * unknown_words(mp_code_text) - returns set of unknown words
+  * unknown_words_regex(mp_code_text)
+  * is_unknown_word(word)
+  * candidate_words(word) - returns list or []
+  * deduplicated_user_whitelist()
+
+Internal data:
+  * _SPELLCHECK_DICT set by calling _set_checker
 """
 
-# strings are immutable so we use dict to preserve this global object
-_WHITELIST_DICT = dict()
+_SPELLCHECK_DICT = {"whitelist_text": None, "checker": None}
 
 def _read_user_whitelist():
     if isfile(SPELLCHECK_WHITELIST_FILENAME):
+        # use established whitelist
         with open (SPELLCHECK_WHITELIST_FILENAME) as f:
             return f.read()
+
+    # user does not have a whitelist file yet
     return ""
 
-def _write_user_whitelist(text):
+def _write_user_whitelist(whitelist_text):
     with open(SPELLCHECK_WHITELIST_FILENAME, "w", encoding='utf-8') as f:
-        f.write(text)
+        f.write(whitelist_text)
+
+def _clean_text(text):
+    # replace everything that is not a letter, number, or apostrophe
+    # with a space
+    text = re.sub(r"[^a-zA-Z0-9']+", " ", text)
+    # replace text containing numbers with space
+    text = re.sub(r"[a-zA-Z0-9']*\d{1,}[a-zA-Z0-9']*", " ", text)
+    # replace short 1 or 2 character long text with space
+    text = re.sub(r"\b[a-zA-Z]{1,2}\b", " ", text)
+    return text
+
+# set the whitelist text and the checker
+def _set_checker(whitelist_text):
+    whitelist_text = _clean_text(whitelist_text)
 
-def _set_spellchecker(whitelist_text):
+    # set whitelist text
+    _SPELLCHECK_DICT["whitelist_text"] = whitelist_text
+
+    # set the checker
     checker = SpellChecker()
 
     # add MP keywords and metasymbols
@@ -47,38 +66,62 @@ def _set_spellchecker(whitelist_text):
     checker.word_frequency.load_text(QTextStream(f).readAll())
 
     # add user's whitelist
-    checker.word_frequency.load_text(whitelist_text)
+    checker.word_frequency.load_text(_SPELLCHECK_DICT["whitelist_text"])
 
-    _WHITELIST_DICT["spellchecker"] = checker
-    _WHITELIST_DICT["user_whitelist"] = whitelist_text
+    _SPELLCHECK_DICT["checker"] = checker
 
-_set_spellchecker(_read_user_whitelist())
+# initialize _SPELLCHECK_DICT
+_set_checker(_read_user_whitelist())
 
-# space-separated words
-def user_whitelist():
-    return _WHITELIST_DICT["user_whitelist"]
+# set the whitelist text and the checker
+def set_checker(whitelist_text):
 
-# save user whitelist and replace the spell checker with an updated one
-def change_user_whitelist(whitelist_text):
-    # prepare cleaned sorted text separated by spaces
-    words = list(set(re.sub(r"[^a-zA-Z ]+", " ", whitelist_text).split()))
-    words.sort(key=str.lower)
-
-    # save
-    whitelist_text = ' '.join(words)
+    # save to file
     _write_user_whitelist(whitelist_text)
-    _set_spellchecker(whitelist_text)
+
+    # set the checker
+    _set_checker(whitelist_text)
+
+def user_whitelist():
+    return _SPELLCHECK_DICT["whitelist_text"]
 
 def unknown_words(mp_code_text):
+    text = _clean_text(mp_code_text)
+    return _SPELLCHECK_DICT["checker"].unknown(text.split())
+
+def unknown_words_regex(mp_code_text):
+    unknown_word_set = unknown_words(mp_code_text)
+    unknown_word_text = '|'.join(unknown_word_set)
+    regex = QRegularExpression(
+              r"(?<=[^a-zA-Z0-9']|^)(%s)(?=[^a-zA-Z0-9']|$)"%unknown_word_text,
+              options=QRegularExpression.CaseInsensitiveOption)
+    return regex
+
+def is_unknown_word(word):
+    return _SPELLCHECK_DICT["checker"].unknown([word])
+
+def candidate_words(unknown_word):
+    candidates = _SPELLCHECK_DICT["checker"].candidates(unknown_word)
+    if not candidates:
+        candidates = set()
+    return sorted(candidates)
+
+# returns the user whitelist sorted and deduplicated
+def deduplicated_user_whitelist():
+    # prepare a checker
+    checker = SpellChecker()
 
-    # replace everything that is not a letter, number, or apostrophe with space
-    text = re.sub(r"[^a-zA-Z0-9']+", " ", mp_code_text)
+    # add MP keywords and metasymbols
+    checker.word_frequency.load_words("|".split(MP_KEYWORDS))
+    checker.word_frequency.load_words("|".split(MP_META_SYMBOLS))
 
-    # replace text containing numbers with space
-    text = re.sub(r"[a-zA-Z0-9']*\d{1,}[a-zA-Z0-9']*", " ", text)
+    # add resource whitelist
+    f = QFile(":/spellcheck_whitelist")
+    f.open(QIODevice.ReadOnly | QIODevice.Text)
+    checker.word_frequency.load_text(QTextStream(f).readAll())
 
-    # replace short 1 or 2 character long text with space
-    text = re.sub(r"\b[a-zA-Z]{1,2}\b", " ", text)
+    deduplicated_words = list(checker.unknown(user_whitelist().split()))
+    deduplicated_words.sort(key=str.lower)
+    return ' '.join(deduplicated_words)
 
-    return _WHITELIST_DICT["spellchecker"].unknown(text.split())
 
diff --git a/python/spellcheck_manager.py b/python/spellcheck_manager.py
deleted file mode 100644
index 38536f01f19cb5b46a368722c130e93652a61e05..0000000000000000000000000000000000000000
--- a/python/spellcheck_manager.py
+++ /dev/null
@@ -1,44 +0,0 @@
-"""
-Use this for managing the spellchecker and user whitelist.
-
-Signals:
-  * signal_spellcheck_changed
-"""
-
-from PySide6.QtCore import QObject, Signal, Slot # for signal/slot support
-from PySide6.QtGui import QAction
-from spellcheck import change_user_whitelist
-from spellcheck_whitelist_dialog_wrapper import SpellcheckWhitelistDialogWrapper
-
-class SpellcheckManager(QObject):
-    """
-    Signal whitelist change during runtime.  Specifically, highlighting
-    wants this.
-    """
-
-    # signal
-    signal_spellcheck_changed = Signal(name='spellcheckChanged')
-
-    def __init__(self, parent_window):
-        super().__init__()
-
-        self.parent_window = parent_window
-
-        # action
-        self.action_spellchecker_whitelist = QAction(
-                                                "Spellchecker whitelist...")
-        self.action_spellchecker_whitelist.setToolTip(
-                      "Set a word whitelist for the code editor spellchecker")
-        self.action_spellchecker_whitelist.triggered.connect(self._open_dialog)
-
-        # singleton non-modal dialog
-        self.dialog = SpellcheckWhitelistDialogWrapper(self.parent_window, self)
-
-    def set_user_whitelist(self, text):
-        change_user_whitelist(text)
-        self.signal_spellcheck_changed.emit()
-
-    @Slot()
-    def _open_dialog(self):
-        self.dialog.show()
-
diff --git a/python/spellcheck_whitelist_dialog.py b/python/spellcheck_whitelist_dialog.py
index 0527006b8879d59ca5b8882b19cbe6de5a04cbae..ef441e07760b165ec4a1b17aa9a5a832b107067f 100644
--- a/python/spellcheck_whitelist_dialog.py
+++ b/python/spellcheck_whitelist_dialog.py
@@ -25,13 +25,16 @@ class Ui_SpellcheckWhitelistDialog(object):
         SpellcheckWhitelistDialog.resize(572, 320)
         self.close_pb = QPushButton(SpellcheckWhitelistDialog)
         self.close_pb.setObjectName(u"close_pb")
-        self.close_pb.setGeometry(QRect(260, 290, 83, 25))
+        self.close_pb.setGeometry(QRect(320, 290, 83, 25))
         self.verticalLayoutWidget = QWidget(SpellcheckWhitelistDialog)
         self.verticalLayoutWidget.setObjectName(u"verticalLayoutWidget")
         self.verticalLayoutWidget.setGeometry(QRect(0, 0, 571, 281))
         self.layout = QVBoxLayout(self.verticalLayoutWidget)
         self.layout.setObjectName(u"layout")
         self.layout.setContentsMargins(0, 0, 0, 0)
+        self.deduplicate_pb = QPushButton(SpellcheckWhitelistDialog)
+        self.deduplicate_pb.setObjectName(u"deduplicate_pb")
+        self.deduplicate_pb.setGeometry(QRect(172, 290, 101, 25))
 
         self.retranslateUi(SpellcheckWhitelistDialog)
 
@@ -41,5 +44,6 @@ class Ui_SpellcheckWhitelistDialog(object):
     def retranslateUi(self, SpellcheckWhitelistDialog):
         SpellcheckWhitelistDialog.setWindowTitle(QCoreApplication.translate("SpellcheckWhitelistDialog", u"MP Code Whitelist", None))
         self.close_pb.setText(QCoreApplication.translate("SpellcheckWhitelistDialog", u"Close", None))
+        self.deduplicate_pb.setText(QCoreApplication.translate("SpellcheckWhitelistDialog", u"Deduplicate", None))
     # retranslateUi
 
diff --git a/python/spellcheck_whitelist_dialog.ui b/python/spellcheck_whitelist_dialog.ui
index 203b580d6f76edae7ac12e31b95f4261c657d3b7..41c710fd0ecf9ff1dbe857ae095dec9d973113b9 100644
--- a/python/spellcheck_whitelist_dialog.ui
+++ b/python/spellcheck_whitelist_dialog.ui
@@ -16,7 +16,7 @@
   <widget class="QPushButton" name="close_pb">
    <property name="geometry">
     <rect>
-     <x>260</x>
+     <x>320</x>
      <y>290</y>
      <width>83</width>
      <height>25</height>
@@ -37,6 +37,19 @@
    </property>
    <layout class="QVBoxLayout" name="layout"/>
   </widget>
+  <widget class="QPushButton" name="deduplicate_pb">
+   <property name="geometry">
+    <rect>
+     <x>172</x>
+     <y>290</y>
+     <width>101</width>
+     <height>25</height>
+    </rect>
+   </property>
+   <property name="text">
+    <string>Deduplicate</string>
+   </property>
+  </widget>
  </widget>
  <resources/>
  <connections/>
diff --git a/python/spellcheck_whitelist_dialog_wrapper.py b/python/spellcheck_whitelist_dialog_wrapper.py
index f6c4c85ceceba22404bc8d9c9ab9f65b7dd14fd3..9043e43768f8b55309c01e8999dfdad4081802d8 100644
--- a/python/spellcheck_whitelist_dialog_wrapper.py
+++ b/python/spellcheck_whitelist_dialog_wrapper.py
@@ -1,13 +1,17 @@
-# wrapper from https://stackoverflow.com/questions/2398800/linking-a-qtdesigner-ui-file-to-python-pyqt
+import re
 from PySide6.QtCore import Qt
-from PySide6.QtCore import Slot # for signal/slot support
+from PySide6.QtCore import Signal, Slot # for signal/slot support
 from PySide6.QtWidgets import QDialog, QPlainTextEdit
-from PySide6.QtGui import QKeyEvent
+from PySide6.QtGui import QKeyEvent, QFocusEvent, QAction
 from spellcheck_whitelist_dialog import Ui_SpellcheckWhitelistDialog
-from spellcheck import user_whitelist
+from spellcheck import user_whitelist, set_checker, user_whitelist, \
+                       deduplicated_user_whitelist
 from verbose import verbose
 
-class SpellcheckWhitelistView(QPlainTextEdit):
+class SpellcheckWhitelistTextEdit(QPlainTextEdit):
+
+    signal_refresh_whitelist = Signal(name='signalRefreshWhitelist')
+
     def __init__(self):
         super().__init__()
 
@@ -28,11 +32,21 @@ class SpellcheckWhitelistView(QPlainTextEdit):
             # keep the text
             super().keyPressEvent(e)
 
+            # refresh on CR
+            if e.key() in (Qt.Key_Return, Qt.Key_Enter):
+                self.signal_refresh_whitelist.emit()
+
+    @Slot(QFocusEvent)
+    def focusOutEvent(self, e):
+        # refresh on lose keyboard focus
+        self.signal_refresh_whitelist.emit()
+        super().focusOutEvent(e)
+
 class SpellcheckWhitelistDialogWrapper(QDialog):
 
-    def __init__(self, parent_window, spellcheck_manager):
+    def __init__(self, parent_window, spellcheck_whitelist_manager):
         super().__init__(parent_window)
-        self.spellcheck_manager = spellcheck_manager
+        self.spellcheck_whitelist_manager = spellcheck_whitelist_manager
         self.ui = Ui_SpellcheckWhitelistDialog()
         self.ui.setupUi(self) # we refine this further below
         self.setFixedSize(self.width(), self.height())
@@ -40,18 +54,40 @@ class SpellcheckWhitelistDialogWrapper(QDialog):
         self.setAttribute(Qt.WA_MacAlwaysShowToolWindow)
         self.setWindowTitle("MP Code Spellcheck Whitelist")
 
-        # the QPlainTextEdit view
-        self.view = SpellcheckWhitelistView()
-        self.ui.layout.addWidget(self.view)
-        self.document = self.view.document()
-        self.document.setPlainText(user_whitelist())
+        # the QPlainTextEdit editor view
+        self.whitelist_text_edit = SpellcheckWhitelistTextEdit()
+        self.ui.layout.addWidget(self.whitelist_text_edit)
+        self.whitelist_text_edit.setPlainText(user_whitelist())
+
+        # action
+        self.action_open_spellcheck_whitelist_dialog = QAction(
+                                                "Spellchecker whitelist...")
+        self.action_open_spellcheck_whitelist_dialog.setToolTip(
+                      "Set a word whitelist for the code editor spellchecker")
+        self.action_open_spellcheck_whitelist_dialog.triggered.connect(
+                                                          self.show)
 
-        # connect buttons
-        self.view.textChanged.connect(self._text_changed)
+        # connect
+        self.ui.deduplicate_pb.clicked.connect(self._deduplicate_text)
         self.ui.close_pb.clicked.connect(self.close) # superclass close
+        self.whitelist_text_edit.signal_refresh_whitelist.connect(
+                                              self._accept_text_changed)
+        spellcheck_whitelist_manager.signal_spellcheck_changed.connect(
+                                            self._replace_editor_text)
+
+    @Slot()
+    def _accept_text_changed(self):
+        text = self.whitelist_text_edit.toPlainText()
+        self.spellcheck_whitelist_manager.set_user_whitelist(text)
+
+    @Slot()
+    def _replace_editor_text(self):
+        if self.whitelist_text_edit.toPlainText() != user_whitelist():
+            # replace text in whitelist text edit view
+            self.whitelist_text_edit.setPlainText(user_whitelist())
 
     @Slot()
-    def _text_changed(self):
-        text = self.document.toPlainText()
-        self.spellcheck_manager.set_user_whitelist(text)
+    def _deduplicate_text(self):
+        text = deduplicated_user_whitelist()
+        self.spellcheck_whitelist_manager.set_user_whitelist(text)
 
diff --git a/python/spellcheck_whitelist_manager.py b/python/spellcheck_whitelist_manager.py
new file mode 100644
index 0000000000000000000000000000000000000000..f2e036c5af499e501e4f92f34fd4b26c8178c13e
--- /dev/null
+++ b/python/spellcheck_whitelist_manager.py
@@ -0,0 +1,47 @@
+from os.path import isfile
+from spellchecker import SpellChecker
+from PySide6.QtCore import QObject, Signal, Slot # for signal/slot support
+from PySide6.QtCore import QFile, QTextStream, QIODevice
+from PySide6.QtGui import QTextDocument, QTextCursor
+from paths_gryphon import SPELLCHECK_WHITELIST_FILENAME
+from mp_code_expressions import MP_KEYWORDS, MP_META_SYMBOLS
+#from spellcheck import change_user_whitelist
+#from spellcheck_whitelist_dialog_wrapper import SpellcheckWhitelistDialogWrapper
+
+from spellcheck import set_checker, user_whitelist
+
+
+class SpellcheckWhitelistManager(QObject):
+    """
+    Use this for updating the user whitelist.
+
+    Use spellcheck for singleton access to the checker.
+
+    The spellcheck whitelist dialog should call set_user_whitelist on CR
+    or on lose focus in order to reset the checker with new whitelist_text.
+    It should not call set_user_whitelist every time text in the dialog
+    changes.
+
+    Interfaces:
+      * set_user_whitelist(whitelist_text) - words separated by space
+      * add_user_whitelist_word(word) - add word
+
+    Signals:
+      * signal_spellcheck_changed(whitelist_text) - The spellcheck
+        whitelist dialog should refresh with new text on this signal.
+    """
+
+    # signal
+    signal_spellcheck_changed = Signal(name='signalSpellcheckChanged')
+
+    def __init__(self):
+        super().__init__()
+
+    def set_user_whitelist(self, whitelist_text):
+        set_checker(whitelist_text)
+        self.signal_spellcheck_changed.emit()
+
+    def add_user_whitelist_word(self, word):
+        set_checker("%s %s"%(user_whitelist(), word))
+        self.signal_spellcheck_changed.emit()
+