diff --git a/src/guiguts/application.py b/src/guiguts/application.py index 66d2929a..decd1fb7 100644 --- a/src/guiguts/application.py +++ b/src/guiguts/application.py @@ -363,6 +363,7 @@ def initialize_preferences(self) -> None: PrefKey.TEXT_FONT_SIZE, lambda *value: maintext().set_font(), ) + preferences.set_default(PrefKey.SPELL_THRESHOLD, 3) # Check all preferences have a default for pref_key in PrefKey: @@ -557,7 +558,7 @@ def init_tools_menu(self) -> None: menu_tools.add_button("Basic Fi~xup...", basic_fixup_check) menu_tools.add_button("~Word Frequency...", word_frequency) menu_tools.add_button( - "~Spelling Check...", + "~Spelling...", lambda: spell_check( self.file.project_dict, self.file.add_good_word_to_project_dictionary ), diff --git a/src/guiguts/checkers.py b/src/guiguts/checkers.py index 9b01f868..bd10174b 100644 --- a/src/guiguts/checkers.py +++ b/src/guiguts/checkers.py @@ -7,6 +7,7 @@ import regex as re +from guiguts.highlight import spotlight_range, remove_spotlights from guiguts.maintext import maintext from guiguts.mainwindow import ScrolledReadOnlyText from guiguts.preferences import ( @@ -818,12 +819,13 @@ def select_entry_by_index(self, entry_index: int, focus: bool = True) -> None: entry = self.entries[entry_index] self.selected_text = entry.text self.selected_text_range = entry.text_range + remove_spotlights() if entry.text_range is not None: if root().state() == "iconic": root().deiconify() start = maintext().index(self.mark_from_rowcol(entry.text_range.start)) end = maintext().index(self.mark_from_rowcol(entry.text_range.end)) - maintext().do_select(IndexRange(start, end)) + spotlight_range(IndexRange(start, end)) maintext().set_insert_index( IndexRowCol(start), focus=(focus and not is_mac()) ) diff --git a/src/guiguts/highlight.py b/src/guiguts/highlight.py index ad80c058..f6bbc46e 100644 --- a/src/guiguts/highlight.py +++ b/src/guiguts/highlight.py @@ -1,15 +1,16 @@ -"""Highlight functionality""" +"""Highlight functionality.""" from enum import auto from guiguts.maintext import maintext from guiguts.preferences import preferences, PrefKey +from guiguts.utilities import IndexRange class Highlight: - """Global highlight settings""" + """Global highlight settings.""" - TAG_QUOTEMARK = auto() + TAG_QUOTEMARK = str(auto()) # Possible future enhancement: # @@ -27,6 +28,14 @@ class Highlight: "Default": {"bg": "#a08dfc", "fg": "black"}, } + TAG_SPOTLIGHT = str(auto()) + + COLORS_SPOTLIGHT = { + "Light": {"bg": "orange", "fg": "black"}, + "Dark": {"bg": "orange", "fg": "white"}, + "Default": {"bg": "orange", "fg": "black"}, + } + def highlight_selection( pat: str, @@ -58,26 +67,16 @@ def highlight_selection( ) -def get_active_theme() -> str: - """Return the current theme name""" - return preferences.get(PrefKey.THEME_NAME) - - def remove_highlights() -> None: - """Remove acvite highlights""" - maintext().tag_delete(str(Highlight.TAG_QUOTEMARK)) + """Remove active highlights.""" + maintext().tag_delete(Highlight.TAG_QUOTEMARK) def highlight_quotemarks(pat: str) -> None: - """Highlight quote marks in current selection which match a pattern""" - theme = get_active_theme() + """Highlight quote marks in current selection which match a pattern.""" remove_highlights() - maintext().tag_configure( - str(Highlight.TAG_QUOTEMARK), - background=Highlight.COLORS_QUOTEMARK[theme]["bg"], - foreground=Highlight.COLORS_QUOTEMARK[theme]["fg"], - ) - highlight_selection(pat, str(Highlight.TAG_QUOTEMARK), regexp=True) + _highlight_configure_tag(Highlight.TAG_QUOTEMARK, Highlight.COLORS_QUOTEMARK) + highlight_selection(pat, Highlight.TAG_QUOTEMARK, regexp=True) def highlight_single_quotes() -> None: @@ -100,3 +99,38 @@ def highlight_double_quotes() -> None: ‟„ DOUBLE {HIGH-REVERSED-9, LOW-9} QUOTATION MARK """ highlight_quotemarks('["“”«»‟„]') + + +def spotlight_range(spot_range: IndexRange) -> None: + """Highlight the given range in the spotlight color. + + Args: + spot_range: The range to be spotlighted. + """ + remove_spotlights() + _highlight_configure_tag(Highlight.TAG_SPOTLIGHT, Highlight.COLORS_SPOTLIGHT) + maintext().tag_add( + Highlight.TAG_SPOTLIGHT, spot_range.start.index(), spot_range.end.index() + ) + + +def remove_spotlights() -> None: + """Remove active spotlights""" + maintext().tag_delete(Highlight.TAG_SPOTLIGHT) + + +def _highlight_configure_tag( + tag_name: str, tag_colors: dict[str, dict[str, str]] +) -> None: + """Configure highlighting tag colors to match the theme. + + Args: + tag_name: Tag to be configured. + tag_colors: Dictionary of fg/bg colors for each theme. + """ + theme = preferences.get(PrefKey.THEME_NAME) + maintext().tag_configure( + tag_name, + background=tag_colors[theme]["bg"], + foreground=tag_colors[theme]["fg"], + ) diff --git a/src/guiguts/preferences.py b/src/guiguts/preferences.py index 5cf37f73..f8796a57 100644 --- a/src/guiguts/preferences.py +++ b/src/guiguts/preferences.py @@ -62,6 +62,7 @@ class PrefKey(StrEnum): COMPOSE_HISTORY = auto() TEXT_FONT_FAMILY = auto() TEXT_FONT_SIZE = auto() + SPELL_THRESHOLD = auto() class Preferences: diff --git a/src/guiguts/spell.py b/src/guiguts/spell.py index 9f7e6070..1ed9dca8 100644 --- a/src/guiguts/spell.py +++ b/src/guiguts/spell.py @@ -12,7 +12,7 @@ from guiguts.checkers import CheckerDialog, CheckerEntry from guiguts.maintext import maintext, FindMatch from guiguts.misc_tools import tool_save -from guiguts.preferences import preferences +from guiguts.preferences import preferences, PersistentInt, PrefKey from guiguts.utilities import ( IndexRowCol, IndexRange, @@ -68,15 +68,37 @@ def __init__(self) -> None: for lang in self.language_list: self.add_words_from_language(lang) - def spell_check_file(self, project_dict: ProjectDict) -> list[SpellingError]: - """Spell check the currently loaded file. + def do_spell_check(self, project_dict: ProjectDict) -> list[SpellingError]: + """Spell check the currently loaded file, or just the selected range(s). Returns: - List of spelling errors in file. + List of spelling errors. """ spelling_errors = [] spelling_counts: dict[str, int] = {} + + minrow = mincol = maxrow = maxcol = 0 + if sel_ranges := maintext().selected_ranges(): + minrow = sel_ranges[0].start.row + mincol = sel_ranges[0].start.col + maxrow = sel_ranges[-1].end.row + maxcol = sel_ranges[-1].end.col + column_selection = len(sel_ranges) > 1 for line, line_num in maintext().get_lines(): + # Handle doing selection only + if sel_ranges: + # If haven't reached the line range, skip + if line_num < minrow: + continue + # If past the line range, stop + if line_num > maxrow: + break + # Clear the columns outside the selection + if column_selection or line_num == minrow: + line = mincol * " " + line[mincol:] + if column_selection or line_num == maxrow: + line = line[:maxcol] + words = re.split(r"[^\p{Alnum}\p{Mark}'’]", line) next_col = 0 for word in words: @@ -312,7 +334,7 @@ def spell_check( logger.error(f"Dictionary not found for language: {exc.language}") return - bad_spellings = _the_spell_checker.spell_check_file(project_dict) + bad_spellings = _the_spell_checker.do_spell_check(project_dict) def process_spelling(checker_entry: CheckerEntry) -> None: """Process the spelling error by adding the word to the project dictionary.""" @@ -338,30 +360,51 @@ def process_spelling(checker_entry: CheckerEntry) -> None: ) frame = ttk.Frame(checker_dialog.header_frame) frame.grid(column=0, row=1, columnspan=2, sticky="NSEW") + ttk.Label( + frame, + text="Threshold ≤ ", + ).grid(column=0, row=0, sticky="NSW") + threshold_spinbox = ttk.Spinbox( + frame, + textvariable=PersistentInt(PrefKey.SPELL_THRESHOLD), + from_=1, + to=999, + width=4, + ) + threshold_spinbox.grid(column=1, row=0, sticky="NW", padx=(0, 10)) + ToolTip( + threshold_spinbox, + "Do not show errors that appear more than this number of times", + ) + project_dict_button = ttk.Button( frame, text="Add to Project Dict", command=lambda: checker_dialog.process_remove_entry_current(all_matching=True), ) - project_dict_button.grid(column=0, row=0, sticky="NSW") + project_dict_button.grid(column=2, row=0, sticky="NSW") skip_button = ttk.Button( frame, text="Skip", command=lambda: checker_dialog.remove_entry_current(all_matching=False), ) - skip_button.grid(column=1, row=0, sticky="NSW") + skip_button.grid(column=3, row=0, sticky="NSW") skip_all_button = ttk.Button( frame, text="Skip All", command=lambda: checker_dialog.remove_entry_current(all_matching=True), ) - skip_all_button.grid(column=2, row=0, sticky="NSW") + skip_all_button.grid(column=4, row=0, sticky="NSW") checker_dialog.reset() # Construct opening line describing the search - checker_dialog.add_header("Start of Spelling Check", "") + sel_only = " (selected text only)" if len(maintext().selected_ranges()) > 0 else "" + checker_dialog.add_header("Start of Spelling Check" + sel_only, "") + threshold = preferences.get(PrefKey.SPELL_THRESHOLD) for spelling in bad_spellings: + if spelling.frequency > threshold: + continue end_rowcol = IndexRowCol( maintext().index(spelling.rowcol.index() + f"+{spelling.count}c") ) @@ -372,5 +415,5 @@ def process_spelling(checker_entry: CheckerEntry) -> None: 0, spelling.count, ) - checker_dialog.add_footer("", "End of Spelling Check") + checker_dialog.add_footer("", "End of Spelling Check" + sel_only) checker_dialog.display_entries() diff --git a/src/guiguts/word_frequency.py b/src/guiguts/word_frequency.py index edfc982c..2c71b87f 100644 --- a/src/guiguts/word_frequency.py +++ b/src/guiguts/word_frequency.py @@ -8,6 +8,7 @@ import regex as re from guiguts.file import PAGE_SEPARATOR_REGEX +from guiguts.highlight import spotlight_range, remove_spotlights from guiguts.maintext import maintext from guiguts.mainwindow import ScrolledReadOnlyText from guiguts.misc_tools import tool_save @@ -608,7 +609,10 @@ def goto_word(self, entry_index: int) -> None: if match_str[1 : match.count + 1] == newline_word: match.rowcol.col += 1 maintext().set_insert_index(match.rowcol, focus=False) - maintext().select_match_text(match) + remove_spotlights() + start_index = match.rowcol.index() + end_index = maintext().index(start_index + f"+{match.count}c") + spotlight_range(IndexRange(start_index, end_index)) self.previous_word = word def search_word(self, event: tk.Event) -> str: