mirror of https://github.com/python/cpython
385 lines
14 KiB
Python
385 lines
14 KiB
Python
import builtins
|
|
import keyword
|
|
import re
|
|
import time
|
|
|
|
from idlelib.config import idleConf
|
|
from idlelib.delegator import Delegator
|
|
|
|
DEBUG = False
|
|
|
|
|
|
def any(name, alternates):
|
|
"Return a named group pattern matching list of alternates."
|
|
return "(?P<%s>" % name + "|".join(alternates) + ")"
|
|
|
|
|
|
def make_pat():
|
|
kw = r"\b" + any("KEYWORD", keyword.kwlist) + r"\b"
|
|
match_softkw = (
|
|
r"^[ \t]*" + # at beginning of line + possible indentation
|
|
r"(?P<MATCH_SOFTKW>match)\b" +
|
|
r"(?![ \t]*(?:" + "|".join([ # not followed by ...
|
|
r"[:,;=^&|@~)\]}]", # a character which means it can't be a
|
|
# pattern-matching statement
|
|
r"\b(?:" + r"|".join(keyword.kwlist) + r")\b", # a keyword
|
|
]) +
|
|
r"))"
|
|
)
|
|
case_default = (
|
|
r"^[ \t]*" + # at beginning of line + possible indentation
|
|
r"(?P<CASE_SOFTKW>case)" +
|
|
r"[ \t]+(?P<CASE_DEFAULT_UNDERSCORE>_\b)"
|
|
)
|
|
case_softkw_and_pattern = (
|
|
r"^[ \t]*" + # at beginning of line + possible indentation
|
|
r"(?P<CASE_SOFTKW2>case)\b" +
|
|
r"(?![ \t]*(?:" + "|".join([ # not followed by ...
|
|
r"_\b", # a lone underscore
|
|
r"[:,;=^&|@~)\]}]", # a character which means it can't be a
|
|
# pattern-matching case
|
|
r"\b(?:" + r"|".join(keyword.kwlist) + r")\b", # a keyword
|
|
]) +
|
|
r"))"
|
|
)
|
|
builtinlist = [str(name) for name in dir(builtins)
|
|
if not name.startswith('_') and
|
|
name not in keyword.kwlist]
|
|
builtin = r"([^.'\"\\#]\b|^)" + any("BUILTIN", builtinlist) + r"\b"
|
|
comment = any("COMMENT", [r"#[^\n]*"])
|
|
stringprefix = r"(?i:r|u|f|fr|rf|b|br|rb)?"
|
|
sqstring = stringprefix + r"'[^'\\\n]*(\\.[^'\\\n]*)*'?"
|
|
dqstring = stringprefix + r'"[^"\\\n]*(\\.[^"\\\n]*)*"?'
|
|
sq3string = stringprefix + r"'''[^'\\]*((\\.|'(?!''))[^'\\]*)*(''')?"
|
|
dq3string = stringprefix + r'"""[^"\\]*((\\.|"(?!""))[^"\\]*)*(""")?'
|
|
string = any("STRING", [sq3string, dq3string, sqstring, dqstring])
|
|
prog = re.compile("|".join([
|
|
builtin, comment, string, kw,
|
|
match_softkw, case_default,
|
|
case_softkw_and_pattern,
|
|
any("SYNC", [r"\n"]),
|
|
]),
|
|
re.DOTALL | re.MULTILINE)
|
|
return prog
|
|
|
|
|
|
prog = make_pat()
|
|
idprog = re.compile(r"\s+(\w+)")
|
|
prog_group_name_to_tag = {
|
|
"MATCH_SOFTKW": "KEYWORD",
|
|
"CASE_SOFTKW": "KEYWORD",
|
|
"CASE_DEFAULT_UNDERSCORE": "KEYWORD",
|
|
"CASE_SOFTKW2": "KEYWORD",
|
|
}
|
|
|
|
|
|
def matched_named_groups(re_match):
|
|
"Get only the non-empty named groups from an re.Match object."
|
|
return ((k, v) for (k, v) in re_match.groupdict().items() if v)
|
|
|
|
|
|
def color_config(text):
|
|
"""Set color options of Text widget.
|
|
|
|
If ColorDelegator is used, this should be called first.
|
|
"""
|
|
# Called from htest, TextFrame, Editor, and Turtledemo.
|
|
# Not automatic because ColorDelegator does not know 'text'.
|
|
theme = idleConf.CurrentTheme()
|
|
normal_colors = idleConf.GetHighlight(theme, 'normal')
|
|
cursor_color = idleConf.GetHighlight(theme, 'cursor')['foreground']
|
|
select_colors = idleConf.GetHighlight(theme, 'hilite')
|
|
text.config(
|
|
foreground=normal_colors['foreground'],
|
|
background=normal_colors['background'],
|
|
insertbackground=cursor_color,
|
|
selectforeground=select_colors['foreground'],
|
|
selectbackground=select_colors['background'],
|
|
inactiveselectbackground=select_colors['background'], # new in 8.5
|
|
)
|
|
|
|
|
|
class ColorDelegator(Delegator):
|
|
"""Delegator for syntax highlighting (text coloring).
|
|
|
|
Instance variables:
|
|
delegate: Delegator below this one in the stack, meaning the
|
|
one this one delegates to.
|
|
|
|
Used to track state:
|
|
after_id: Identifier for scheduled after event, which is a
|
|
timer for colorizing the text.
|
|
allow_colorizing: Boolean toggle for applying colorizing.
|
|
colorizing: Boolean flag when colorizing is in process.
|
|
stop_colorizing: Boolean flag to end an active colorizing
|
|
process.
|
|
"""
|
|
|
|
def __init__(self):
|
|
Delegator.__init__(self)
|
|
self.init_state()
|
|
self.prog = prog
|
|
self.idprog = idprog
|
|
self.LoadTagDefs()
|
|
|
|
def init_state(self):
|
|
"Initialize variables that track colorizing state."
|
|
self.after_id = None
|
|
self.allow_colorizing = True
|
|
self.stop_colorizing = False
|
|
self.colorizing = False
|
|
|
|
def setdelegate(self, delegate):
|
|
"""Set the delegate for this instance.
|
|
|
|
A delegate is an instance of a Delegator class and each
|
|
delegate points to the next delegator in the stack. This
|
|
allows multiple delegators to be chained together for a
|
|
widget. The bottom delegate for a colorizer is a Text
|
|
widget.
|
|
|
|
If there is a delegate, also start the colorizing process.
|
|
"""
|
|
if self.delegate is not None:
|
|
self.unbind("<<toggle-auto-coloring>>")
|
|
Delegator.setdelegate(self, delegate)
|
|
if delegate is not None:
|
|
self.config_colors()
|
|
self.bind("<<toggle-auto-coloring>>", self.toggle_colorize_event)
|
|
self.notify_range("1.0", "end")
|
|
else:
|
|
# No delegate - stop any colorizing.
|
|
self.stop_colorizing = True
|
|
self.allow_colorizing = False
|
|
|
|
def config_colors(self):
|
|
"Configure text widget tags with colors from tagdefs."
|
|
for tag, cnf in self.tagdefs.items():
|
|
self.tag_configure(tag, **cnf)
|
|
self.tag_raise('sel')
|
|
|
|
def LoadTagDefs(self):
|
|
"Create dictionary of tag names to text colors."
|
|
theme = idleConf.CurrentTheme()
|
|
self.tagdefs = {
|
|
"COMMENT": idleConf.GetHighlight(theme, "comment"),
|
|
"KEYWORD": idleConf.GetHighlight(theme, "keyword"),
|
|
"BUILTIN": idleConf.GetHighlight(theme, "builtin"),
|
|
"STRING": idleConf.GetHighlight(theme, "string"),
|
|
"DEFINITION": idleConf.GetHighlight(theme, "definition"),
|
|
"SYNC": {'background': None, 'foreground': None},
|
|
"TODO": {'background': None, 'foreground': None},
|
|
"ERROR": idleConf.GetHighlight(theme, "error"),
|
|
# "hit" is used by ReplaceDialog to mark matches. It shouldn't be changed by Colorizer, but
|
|
# that currently isn't technically possible. This should be moved elsewhere in the future
|
|
# when fixing the "hit" tag's visibility, or when the replace dialog is replaced with a
|
|
# non-modal alternative.
|
|
"hit": idleConf.GetHighlight(theme, "hit"),
|
|
}
|
|
if DEBUG: print('tagdefs', self.tagdefs)
|
|
|
|
def insert(self, index, chars, tags=None):
|
|
"Insert chars into widget at index and mark for colorizing."
|
|
index = self.index(index)
|
|
self.delegate.insert(index, chars, tags)
|
|
self.notify_range(index, index + "+%dc" % len(chars))
|
|
|
|
def delete(self, index1, index2=None):
|
|
"Delete chars between indexes and mark for colorizing."
|
|
index1 = self.index(index1)
|
|
self.delegate.delete(index1, index2)
|
|
self.notify_range(index1)
|
|
|
|
def notify_range(self, index1, index2=None):
|
|
"Mark text changes for processing and restart colorizing, if active."
|
|
self.tag_add("TODO", index1, index2)
|
|
if self.after_id:
|
|
if DEBUG: print("colorizing already scheduled")
|
|
return
|
|
if self.colorizing:
|
|
self.stop_colorizing = True
|
|
if DEBUG: print("stop colorizing")
|
|
if self.allow_colorizing:
|
|
if DEBUG: print("schedule colorizing")
|
|
self.after_id = self.after(1, self.recolorize)
|
|
return
|
|
|
|
def close(self):
|
|
if self.after_id:
|
|
after_id = self.after_id
|
|
self.after_id = None
|
|
if DEBUG: print("cancel scheduled recolorizer")
|
|
self.after_cancel(after_id)
|
|
self.allow_colorizing = False
|
|
self.stop_colorizing = True
|
|
|
|
def toggle_colorize_event(self, event=None):
|
|
"""Toggle colorizing on and off.
|
|
|
|
When toggling off, if colorizing is scheduled or is in
|
|
process, it will be cancelled and/or stopped.
|
|
|
|
When toggling on, colorizing will be scheduled.
|
|
"""
|
|
if self.after_id:
|
|
after_id = self.after_id
|
|
self.after_id = None
|
|
if DEBUG: print("cancel scheduled recolorizer")
|
|
self.after_cancel(after_id)
|
|
if self.allow_colorizing and self.colorizing:
|
|
if DEBUG: print("stop colorizing")
|
|
self.stop_colorizing = True
|
|
self.allow_colorizing = not self.allow_colorizing
|
|
if self.allow_colorizing and not self.colorizing:
|
|
self.after_id = self.after(1, self.recolorize)
|
|
if DEBUG:
|
|
print("auto colorizing turned",
|
|
"on" if self.allow_colorizing else "off")
|
|
return "break"
|
|
|
|
def recolorize(self):
|
|
"""Timer event (every 1ms) to colorize text.
|
|
|
|
Colorizing is only attempted when the text widget exists,
|
|
when colorizing is toggled on, and when the colorizing
|
|
process is not already running.
|
|
|
|
After colorizing is complete, some cleanup is done to
|
|
make sure that all the text has been colorized.
|
|
"""
|
|
self.after_id = None
|
|
if not self.delegate:
|
|
if DEBUG: print("no delegate")
|
|
return
|
|
if not self.allow_colorizing:
|
|
if DEBUG: print("auto colorizing is off")
|
|
return
|
|
if self.colorizing:
|
|
if DEBUG: print("already colorizing")
|
|
return
|
|
try:
|
|
self.stop_colorizing = False
|
|
self.colorizing = True
|
|
if DEBUG: print("colorizing...")
|
|
t0 = time.perf_counter()
|
|
self.recolorize_main()
|
|
t1 = time.perf_counter()
|
|
if DEBUG: print("%.3f seconds" % (t1-t0))
|
|
finally:
|
|
self.colorizing = False
|
|
if self.allow_colorizing and self.tag_nextrange("TODO", "1.0"):
|
|
if DEBUG: print("reschedule colorizing")
|
|
self.after_id = self.after(1, self.recolorize)
|
|
|
|
def recolorize_main(self):
|
|
"Evaluate text and apply colorizing tags."
|
|
next = "1.0"
|
|
while todo_tag_range := self.tag_nextrange("TODO", next):
|
|
self.tag_remove("SYNC", todo_tag_range[0], todo_tag_range[1])
|
|
sync_tag_range = self.tag_prevrange("SYNC", todo_tag_range[0])
|
|
head = sync_tag_range[1] if sync_tag_range else "1.0"
|
|
|
|
chars = ""
|
|
next = head
|
|
lines_to_get = 1
|
|
ok = False
|
|
while not ok:
|
|
mark = next
|
|
next = self.index(mark + "+%d lines linestart" %
|
|
lines_to_get)
|
|
lines_to_get = min(lines_to_get * 2, 100)
|
|
ok = "SYNC" in self.tag_names(next + "-1c")
|
|
line = self.get(mark, next)
|
|
##print head, "get", mark, next, "->", repr(line)
|
|
if not line:
|
|
return
|
|
for tag in self.tagdefs:
|
|
self.tag_remove(tag, mark, next)
|
|
chars += line
|
|
self._add_tags_in_section(chars, head)
|
|
if "SYNC" in self.tag_names(next + "-1c"):
|
|
head = next
|
|
chars = ""
|
|
else:
|
|
ok = False
|
|
if not ok:
|
|
# We're in an inconsistent state, and the call to
|
|
# update may tell us to stop. It may also change
|
|
# the correct value for "next" (since this is a
|
|
# line.col string, not a true mark). So leave a
|
|
# crumb telling the next invocation to resume here
|
|
# in case update tells us to leave.
|
|
self.tag_add("TODO", next)
|
|
self.update()
|
|
if self.stop_colorizing:
|
|
if DEBUG: print("colorizing stopped")
|
|
return
|
|
|
|
def _add_tag(self, start, end, head, matched_group_name):
|
|
"""Add a tag to a given range in the text widget.
|
|
|
|
This is a utility function, receiving the range as `start` and
|
|
`end` positions, each of which is a number of characters
|
|
relative to the given `head` index in the text widget.
|
|
|
|
The tag to add is determined by `matched_group_name`, which is
|
|
the name of a regular expression "named group" as matched by
|
|
by the relevant highlighting regexps.
|
|
"""
|
|
tag = prog_group_name_to_tag.get(matched_group_name,
|
|
matched_group_name)
|
|
self.tag_add(tag,
|
|
f"{head}+{start:d}c",
|
|
f"{head}+{end:d}c")
|
|
|
|
def _add_tags_in_section(self, chars, head):
|
|
"""Parse and add highlighting tags to a given part of the text.
|
|
|
|
`chars` is a string with the text to parse and to which
|
|
highlighting is to be applied.
|
|
|
|
`head` is the index in the text widget where the text is found.
|
|
"""
|
|
for m in self.prog.finditer(chars):
|
|
for name, matched_text in matched_named_groups(m):
|
|
a, b = m.span(name)
|
|
self._add_tag(a, b, head, name)
|
|
if matched_text in ("def", "class"):
|
|
if m1 := self.idprog.match(chars, b):
|
|
a, b = m1.span(1)
|
|
self._add_tag(a, b, head, "DEFINITION")
|
|
|
|
def removecolors(self):
|
|
"Remove all colorizing tags."
|
|
for tag in self.tagdefs:
|
|
self.tag_remove(tag, "1.0", "end")
|
|
|
|
|
|
def _color_delegator(parent): # htest #
|
|
from tkinter import Toplevel, Text
|
|
from idlelib.idle_test.test_colorizer import source
|
|
from idlelib.percolator import Percolator
|
|
|
|
top = Toplevel(parent)
|
|
top.title("Test ColorDelegator")
|
|
x, y = map(int, parent.geometry().split('+')[1:])
|
|
top.geometry("700x550+%d+%d" % (x + 20, y + 175))
|
|
|
|
text = Text(top, background="white")
|
|
text.pack(expand=1, fill="both")
|
|
text.insert("insert", source)
|
|
text.focus_set()
|
|
|
|
color_config(text)
|
|
p = Percolator(text)
|
|
d = ColorDelegator()
|
|
p.insertfilter(d)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
from unittest import main
|
|
main('idlelib.idle_test.test_colorizer', verbosity=2, exit=False)
|
|
|
|
from idlelib.idle_test.htest import run
|
|
run(_color_delegator)
|