From cb57f7cbe16ff5cf07569ff80fdd1946bebc5551 Mon Sep 17 00:00:00 2001 From: Dan Lowe Date: Fri, 8 Nov 2024 21:45:26 -0500 Subject: [PATCH] Highlight quote marks & brackets When activated from the Search menu, this mode finds pairs of quote marks and brackets surrounding the cursor position and highlights them. Only pairs are highlighted, and only the nearest surrounding pair (of each type) surrounding the cursor is highlighted. Also fixes a failing test (on macOS) in pytest. Fixes #42 --- src/guiguts/application.py | 9 + src/guiguts/highlight.py | 332 +++++++++++++++++++++++++++++++++++-- src/guiguts/maintext.py | 6 + src/guiguts/preferences.py | 1 + src/guiguts/root.py | 1 + tests/test_guiguts.py | 2 +- 6 files changed, 334 insertions(+), 17 deletions(-) diff --git a/src/guiguts/application.py b/src/guiguts/application.py index 6d9b0d4f..14d2634c 100644 --- a/src/guiguts/application.py +++ b/src/guiguts/application.py @@ -21,6 +21,7 @@ highlight_single_quotes, highlight_double_quotes, remove_highlights, + highlight_quotbrac_callback, ) from guiguts.maintext import maintext from guiguts.mainwindow import ( @@ -412,6 +413,10 @@ def initialize_preferences(self) -> None: str(DEFAULT_SCANNOS_DIR.joinpath(DEFAULT_STEALTH_SCANNOS)), ], ) + preferences.set_default(PrefKey.HIGHLIGHT_QUOTBRAC, False) + preferences.set_callback( + PrefKey.HIGHLIGHT_QUOTBRAC, highlight_quotbrac_callback + ) # Check all preferences have a default for pref_key in PrefKey: @@ -582,6 +587,10 @@ def init_search_menu(self) -> None: "Highlight ~Double Quotes in Selection", highlight_double_quotes, ) + menu_search.add_checkbox( + "Highlight S~urrounding Quotes & Brackets", + root().highlight_quotbrac, + ) menu_search.add_button( "~Remove Highlights", remove_highlights, diff --git a/src/guiguts/highlight.py b/src/guiguts/highlight.py index e75f557e..e2c5adc2 100644 --- a/src/guiguts/highlight.py +++ b/src/guiguts/highlight.py @@ -12,6 +12,13 @@ class HighlightTag(StrEnum): QUOTEMARK = auto() SPOTLIGHT = auto() + PAREN = auto() + CURLY_BRACKET = auto() + SQUARE_BRACKET = auto() + STRAIGHT_DOUBLE_QUOTE = auto() + CURLY_DOUBLE_QUOTE = auto() + STRAIGHT_SINGLE_QUOTE = auto() + CURLY_SINGLE_QUOTE = auto() class HighlightColors: @@ -39,6 +46,48 @@ class HighlightColors: "Default": {"bg": "orange", "fg": "black"}, } + PAREN = { + "Light": {"bg": "violet", "fg": "white"}, + "Dark": {"bg": "violet", "fg": "white"}, + "Default": {"bg": "violet", "fg": "white"}, + } + + CURLY_BRACKET = { + "Light": {"bg": "blue", "fg": "white"}, + "Dark": {"bg": "blue", "fg": "white"}, + "Default": {"bg": "blue", "fg": "white"}, + } + + SQUARE_BRACKET = { + "Light": {"bg": "purple", "fg": "white"}, + "Dark": {"bg": "purple", "fg": "white"}, + "Default": {"bg": "purple", "fg": "white"}, + } + + STRAIGHT_DOUBLE_QUOTE = { + "Light": {"bg": "green", "fg": "white"}, + "Dark": {"bg": "green", "fg": "white"}, + "Default": {"bg": "green", "fg": "white"}, + } + + CURLY_DOUBLE_QUOTE = { + "Light": {"bg": "limegreen", "fg": "white"}, + "Dark": {"bg": "limegreen", "fg": "white"}, + "Default": {"bg": "limegreen", "fg": "white"}, + } + + STRAIGHT_SINGLE_QUOTE = { + "Light": {"bg": "grey", "fg": "white"}, + "Dark": {"bg": "grey", "fg": "white"}, + "Default": {"bg": "grey", "fg": "white"}, + } + + CURLY_SINGLE_QUOTE = { + "Light": {"bg": "dodgerblue", "fg": "white"}, + "Dark": {"bg": "dodgerblue", "fg": "white"}, + "Default": {"bg": "dodgerblue", "fg": "white"}, + } + def highlight_selection( pat: str, @@ -79,25 +128,13 @@ def highlight_quotemarks(pat: str) -> None: def highlight_single_quotes() -> None: - """Highlight single quotes in current selection. - - ' APOSTROPHE - ‘’ {LEFT, RIGHT} SINGLE QUOTATION MARK - ‹› SINGLE {LEFT, RIGHT}-POINTING ANGLE QUOTATION MARK - ‛‚ SINGLE {HIGH-REVERSED-9, LOW-9} QUOTATION MARK - """ - highlight_quotemarks("['‘’‹›‛‚]") + """Highlight single quotes (straight or curly) in current selection.""" + highlight_quotemarks("['‘’]") def highlight_double_quotes() -> None: - """Highlight double quotes in current selection. - - " QUOTATION MARK - “” {LEFT, RIGHT} DOUBLE QUOTATION MARK - «» {LEFT, RIGHT}-POINTING DOUBLE ANGLE QUOTATION MARK - ‟„ DOUBLE {HIGH-REVERSED-9, LOW-9} QUOTATION MARK - """ - highlight_quotemarks('["“”«»‟„]') + """Highlight double quotes (straight or curly) in current selection.""" + highlight_quotemarks('["“”]') def spotlight_range(spot_range: IndexRange) -> None: @@ -133,3 +170,266 @@ def _highlight_configure_tag( background=tag_colors[theme]["bg"], foreground=tag_colors[theme]["fg"], ) + + +def highlight_single_pair_bracketing_cursor( + startchar: str, + endchar: str, + tag_name: str, + tag_colors: dict[str, dict[str, str]], + *, + charpair: str = "", +) -> None: + """ + Search for a pair of matching characters that bracket the cursor and tag + them with the given tagname. If charpair is not specified, a default regex + of f"[{startchar}{endchar}]" will be used. + + Args: + startchar: opening char of pair (e.g. '(') + endchar: closing chair of pair (e.g. ')') + tag_name: name of tag for highlighting (class HighlightTag) + tag_colors: dict of color information (class HighlightColors) + charpair: optional regex override for matching the pair (e.g. '[][]') + """ + maintext().tag_delete(tag_name) + _highlight_configure_tag(tag_name, tag_colors) + cursor = maintext().get_insert_index().index() + + (top_index, bot_index) = get_screen_window_coordinates() + + # search backward for the startchar + startindex = search_for_base_character_in_pair( + top_index, + cursor, + bot_index, + startchar, + endchar, + charpair=charpair, + backwards=True, + ) + if not startindex: + return + + # search forward for the endchar + endindex = search_for_base_character_in_pair( + top_index, cursor, bot_index, startchar, endchar, charpair=charpair + ) + + if not (startindex and endindex): + return + + maintext().tag_add(tag_name, startindex, maintext().index(f"{startindex}+1c")) + maintext().tag_add(tag_name, endindex, maintext().index(f"{endindex}+1c")) + + +def get_screen_window_coordinates() -> tuple[str, str]: + """ + Find start and end coordinates for viewport (with a margin of + offscreen text added for padding). + """ + # A magic number cribbed from Tk.pm's TextEdit.pm. + # This is how many rows to explore beyond the visible viewport. + offscreen_rows = 80 + + (top_frac, bot_frac) = maintext().focus_widget().yview() + end_index = maintext().rowcol("end") + + # Don't try to go beyond the boundaries of the document. + # + # {top,bot}_frac contain a fractional number representing a percentage into + # the document; do some math to calculate what the top or bottom row in the + # viewport should be, then use min/max to make sure that value isn't less + # than 0 or more than the total row count. + top_line = max(int((top_frac * end_index.row) - offscreen_rows), 0) + bot_line = min(int((bot_frac * end_index.row) + offscreen_rows), end_index.row) + + return (f"{top_line}.0", f"{bot_line}.0") + + +def search_for_base_character_in_pair( + top_index: str, + searchfromindex: str, + bot_index: str, + startchar: str, + endchar: str, + *, + backwards: bool = False, + charpair: str = "", +) -> str: + """ + If searching backward, count characters (e.g. parenthesis) until finding a + startchar which does not have a forward matching endchar. + + (<= search backward will return this index + () + START X HERE + ( ( ) () ) + )<== search forward will return this index + + If searching forward, count characters until finding an endchar which does + not have a rearward matching startchar. + + Default search direction is forward. + + If charpair is not specified, a default regex is constructed from startchar, + endchar using f"[{startchar}{endchar}]". For example, + + startchar='(', endchar=')' results in: charpar='[()]' + """ + + forwards = True + if backwards: + forwards = False + + if not charpair: + if startchar == endchar: + charpair = startchar + else: + charpair = f"[{startchar}{endchar}]" + + if forwards: + plus_one_char = endchar + search_end_index = bot_index + index_offset = " +1c" + done_index = maintext().index("end") + else: + plus_one_char = startchar + search_end_index = top_index + index_offset = "" + done_index = "1.0" + + at_done_index = False + count = 0 + + while True: + searchfromindex = maintext().search( + charpair, + searchfromindex, + search_end_index, + backwards=backwards, + forwards=forwards, + regexp=True, + ) + + if not searchfromindex: + break + + # get one character at the identified index + char = maintext().get(searchfromindex) + if char == plus_one_char: + count += 1 + else: + count -= 1 + + if count == 1: + break + + # boundary condition exists when first char in widget is the match char + # need to be able to determine if search tried to go past index '1.0' + # if so, set index to undef and return. + if at_done_index: + searchfromindex = "" + break + + if searchfromindex == done_index: + at_done_index = True + + searchfromindex = maintext().index(f"{searchfromindex}{index_offset}") + + return searchfromindex + + +def highlight_parens_around_cursor() -> None: + """Highlight pair of parens that most closely brackets the cursor.""" + highlight_single_pair_bracketing_cursor( + "(", + ")", + HighlightTag.PAREN, + HighlightColors.PAREN, + ) + + +def highlight_curly_brackets_around_cursor() -> None: + """Highlight pair of curly brackets that most closely brackets the cursor.""" + highlight_single_pair_bracketing_cursor( + "{", + "}", + HighlightTag.CURLY_BRACKET, + HighlightColors.CURLY_BRACKET, + ) + + +def highlight_square_brackets_around_cursor() -> None: + """Highlight pair of square brackets that most closely brackets the cursor.""" + highlight_single_pair_bracketing_cursor( + "[", + "]", + HighlightTag.SQUARE_BRACKET, + HighlightColors.SQUARE_BRACKET, + charpair="[][]", + ) + + +def highlight_double_quotes_around_cursor() -> None: + """Highlight pair of double quotes that most closely brackets the cursor.""" + highlight_single_pair_bracketing_cursor( + '"', + '"', + HighlightTag.STRAIGHT_DOUBLE_QUOTE, + HighlightColors.STRAIGHT_DOUBLE_QUOTE, + ) + highlight_single_pair_bracketing_cursor( + "“", + "”", + HighlightTag.CURLY_DOUBLE_QUOTE, + HighlightColors.CURLY_DOUBLE_QUOTE, + ) + + +def highlight_single_quotes_around_cursor() -> None: + """Highlight pair of single quotes that most closely brackets the cursor.""" + highlight_single_pair_bracketing_cursor( + "'", + "'", + HighlightTag.STRAIGHT_SINGLE_QUOTE, + HighlightColors.STRAIGHT_SINGLE_QUOTE, + ) + highlight_single_pair_bracketing_cursor( + "‘", + "’", + HighlightTag.CURLY_SINGLE_QUOTE, + HighlightColors.CURLY_SINGLE_QUOTE, + ) + + +def highlight_quotbrac_callback(value: bool) -> None: + """Callback when highlight_quotbrac preference is changed.""" + if value: + highlight_quotbrac() + else: + remove_highlights_quotbrac() + + +def highlight_quotbrac() -> None: + """Highlight all the character pairs that most closely bracket the cursor.""" + if preferences.get(PrefKey.HIGHLIGHT_QUOTBRAC): + highlight_parens_around_cursor() + highlight_curly_brackets_around_cursor() + highlight_square_brackets_around_cursor() + highlight_double_quotes_around_cursor() + highlight_single_quotes_around_cursor() + + +def remove_highlights_quotbrac() -> None: + """Remove highlights for quotes & brackets""" + for tag in ( + HighlightTag.PAREN, + HighlightTag.CURLY_BRACKET, + HighlightTag.SQUARE_BRACKET, + HighlightTag.STRAIGHT_DOUBLE_QUOTE, + HighlightTag.CURLY_DOUBLE_QUOTE, + HighlightTag.STRAIGHT_SINGLE_QUOTE, + HighlightTag.CURLY_SINGLE_QUOTE, + ): + maintext().tag_delete(tag) diff --git a/src/guiguts/maintext.py b/src/guiguts/maintext.py index 726b9fd2..539429e1 100644 --- a/src/guiguts/maintext.py +++ b/src/guiguts/maintext.py @@ -520,10 +520,16 @@ def _on_change(self, *_args: Any) -> None: By setting flag now, and queuing calls to _do_linenumbers_redraw, we ensure the flag will be true for the first call to _do_linenumbers_redraw.""" + # import here to avoid circular import problem. + from guiguts.highlight import ( # pylint: disable=import-outside-toplevel + highlight_quotbrac, + ) + if not self.numbers_need_updating: self.root.after_idle(self._do_linenumbers_redraw) self.root.after_idle(self._call_config_callbacks) self.root.after_idle(self.save_sash_coords) + self.root.after_idle(highlight_quotbrac) self.numbers_need_updating = True def save_sash_coords(self) -> None: diff --git a/src/guiguts/preferences.py b/src/guiguts/preferences.py index 8571ec5a..6f20f729 100644 --- a/src/guiguts/preferences.py +++ b/src/guiguts/preferences.py @@ -81,6 +81,7 @@ class PrefKey(StrEnum): IMAGE_SCALE_FACTOR = auto() SCANNOS_FILENAME = auto() SCANNOS_HISTORY = auto() + HIGHLIGHT_QUOTBRAC = auto() class Preferences: diff --git a/src/guiguts/root.py b/src/guiguts/root.py index 2ef07d0d..604297ab 100644 --- a/src/guiguts/root.py +++ b/src/guiguts/root.py @@ -44,6 +44,7 @@ def __init__(self, **kwargs: Any) -> None: self.ordinal_names_state = PersistentBoolean(PrefKey.ORDINAL_NAMES) self.allow_config_saves = False self.split_text_window = PersistentBoolean(PrefKey.SPLIT_TEXT_WINDOW) + self.highlight_quotbrac = PersistentBoolean(PrefKey.HIGHLIGHT_QUOTBRAC) self.option_add("*tearOff", preferences.get(PrefKey.TEAROFF_MENUS)) self.rowconfigure(0, weight=1) diff --git a/tests/test_guiguts.py b/tests/test_guiguts.py index 6bfda244..837c6bc6 100644 --- a/tests/test_guiguts.py +++ b/tests/test_guiguts.py @@ -56,7 +56,7 @@ def test_mainwindow() -> None: (accel, event) = process_accel("Cmd/Ctrl+y") if is_mac(): assert accel == "Cmd+y" - assert event == "" + assert event == "" (accel, event) = process_accel("Cmd+?") assert accel == "Cmd+?" assert event == ""