Skip to content

Commit

Permalink
Highlight quote marks & brackets
Browse files Browse the repository at this point in the history
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 DistributedProofreaders#42
  • Loading branch information
tangledhelix committed Nov 13, 2024
1 parent eec8fd0 commit cb57f7c
Show file tree
Hide file tree
Showing 6 changed files with 334 additions and 17 deletions.
9 changes: 9 additions & 0 deletions src/guiguts/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
highlight_single_quotes,
highlight_double_quotes,
remove_highlights,
highlight_quotbrac_callback,
)
from guiguts.maintext import maintext
from guiguts.mainwindow import (
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
332 changes: 316 additions & 16 deletions src/guiguts/highlight.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Loading

0 comments on commit cb57f7c

Please sign in to comment.