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 == ""