Coverage for colorizer.py: 20%
200 statements
« prev ^ index » next coverage.py v7.2.5, created at 2023-05-11 13:22 -0700
« prev ^ index » next coverage.py v7.2.5, created at 2023-05-11 13:22 -0700
1import builtins
2import keyword
3import re
4import time
6from idlelib.config import idleConf
7from idlelib.delegator import Delegator
9DEBUG = False
12def any(name, alternates):
13 "Return a named group pattern matching list of alternates."
14 return "(?P<%s>" % name + "|".join(alternates) + ")" 1acb
17def make_pat():
18 kw = r"\b" + any("KEYWORD", keyword.kwlist) + r"\b" 1ab
19 match_softkw = ( 1ab
20 r"^[ \t]*" + # at beginning of line + possible indentation
21 r"(?P<MATCH_SOFTKW>match)\b" +
22 r"(?![ \t]*(?:" + "|".join([ # not followed by ...
23 r"[:,;=^&|@~)\]}]", # a character which means it can't be a
24 # pattern-matching statement
25 r"\b(?:" + r"|".join(keyword.kwlist) + r")\b", # a keyword
26 ]) +
27 r"))"
28 )
29 case_default = ( 1ab
30 r"^[ \t]*" + # at beginning of line + possible indentation
31 r"(?P<CASE_SOFTKW>case)" +
32 r"[ \t]+(?P<CASE_DEFAULT_UNDERSCORE>_\b)"
33 )
34 case_softkw_and_pattern = ( 1ab
35 r"^[ \t]*" + # at beginning of line + possible indentation
36 r"(?P<CASE_SOFTKW2>case)\b" +
37 r"(?![ \t]*(?:" + "|".join([ # not followed by ...
38 r"_\b", # a lone underscore
39 r"[:,;=^&|@~)\]}]", # a character which means it can't be a
40 # pattern-matching case
41 r"\b(?:" + r"|".join(keyword.kwlist) + r")\b", # a keyword
42 ]) +
43 r"))"
44 )
45 builtinlist = [str(name) for name in dir(builtins) 1ab
46 if not name.startswith('_') and
47 name not in keyword.kwlist]
48 builtin = r"([^.'\"\\#]\b|^)" + any("BUILTIN", builtinlist) + r"\b" 1ab
49 comment = any("COMMENT", [r"#[^\n]*"]) 1ab
50 stringprefix = r"(?i:r|u|f|fr|rf|b|br|rb)?" 1ab
51 sqstring = stringprefix + r"'[^'\\\n]*(\\.[^'\\\n]*)*'?" 1ab
52 dqstring = stringprefix + r'"[^"\\\n]*(\\.[^"\\\n]*)*"?' 1ab
53 sq3string = stringprefix + r"'''[^'\\]*((\\.|'(?!''))[^'\\]*)*(''')?" 1ab
54 dq3string = stringprefix + r'"""[^"\\]*((\\.|"(?!""))[^"\\]*)*(""")?' 1ab
55 string = any("STRING", [sq3string, dq3string, sqstring, dqstring]) 1ab
56 prog = re.compile("|".join([ 1ab
57 builtin, comment, string, kw,
58 match_softkw, case_default,
59 case_softkw_and_pattern,
60 any("SYNC", [r"\n"]),
61 ]),
62 re.DOTALL | re.MULTILINE)
63 return prog 1ab
66prog = make_pat()
67idprog = re.compile(r"\s+(\w+)")
68prog_group_name_to_tag = {
69 "MATCH_SOFTKW": "KEYWORD",
70 "CASE_SOFTKW": "KEYWORD",
71 "CASE_DEFAULT_UNDERSCORE": "KEYWORD",
72 "CASE_SOFTKW2": "KEYWORD",
73}
76def matched_named_groups(re_match):
77 "Get only the non-empty named groups from an re.Match object."
78 return ((k, v) for (k, v) in re_match.groupdict().items() if v)
81def color_config(text):
82 """Set color options of Text widget.
84 If ColorDelegator is used, this should be called first.
85 """
86 # Called from htest, TextFrame, Editor, and Turtledemo.
87 # Not automatic because ColorDelegator does not know 'text'.
88 theme = idleConf.CurrentTheme()
89 normal_colors = idleConf.GetHighlight(theme, 'normal')
90 cursor_color = idleConf.GetHighlight(theme, 'cursor')['foreground']
91 select_colors = idleConf.GetHighlight(theme, 'hilite')
92 text.config(
93 foreground=normal_colors['foreground'],
94 background=normal_colors['background'],
95 insertbackground=cursor_color,
96 selectforeground=select_colors['foreground'],
97 selectbackground=select_colors['background'],
98 inactiveselectbackground=select_colors['background'], # new in 8.5
99 )
102class ColorDelegator(Delegator):
103 """Delegator for syntax highlighting (text coloring).
105 Instance variables:
106 delegate: Delegator below this one in the stack, meaning the
107 one this one delegates to.
109 Used to track state:
110 after_id: Identifier for scheduled after event, which is a
111 timer for colorizing the text.
112 allow_colorizing: Boolean toggle for applying colorizing.
113 colorizing: Boolean flag when colorizing is in process.
114 stop_colorizing: Boolean flag to end an active colorizing
115 process.
116 """
118 def __init__(self):
119 Delegator.__init__(self)
120 self.init_state()
121 self.prog = prog
122 self.idprog = idprog
123 self.LoadTagDefs()
125 def init_state(self):
126 "Initialize variables that track colorizing state."
127 self.after_id = None
128 self.allow_colorizing = True
129 self.stop_colorizing = False
130 self.colorizing = False
132 def setdelegate(self, delegate):
133 """Set the delegate for this instance.
135 A delegate is an instance of a Delegator class and each
136 delegate points to the next delegator in the stack. This
137 allows multiple delegators to be chained together for a
138 widget. The bottom delegate for a colorizer is a Text
139 widget.
141 If there is a delegate, also start the colorizing process.
142 """
143 if self.delegate is not None:
144 self.unbind("<<toggle-auto-coloring>>")
145 Delegator.setdelegate(self, delegate)
146 if delegate is not None:
147 self.config_colors()
148 self.bind("<<toggle-auto-coloring>>", self.toggle_colorize_event)
149 self.notify_range("1.0", "end")
150 else:
151 # No delegate - stop any colorizing.
152 self.stop_colorizing = True
153 self.allow_colorizing = False
155 def config_colors(self):
156 "Configure text widget tags with colors from tagdefs."
157 for tag, cnf in self.tagdefs.items():
158 self.tag_configure(tag, **cnf)
159 self.tag_raise('sel')
161 def LoadTagDefs(self):
162 "Create dictionary of tag names to text colors."
163 theme = idleConf.CurrentTheme()
164 self.tagdefs = {
165 "COMMENT": idleConf.GetHighlight(theme, "comment"),
166 "KEYWORD": idleConf.GetHighlight(theme, "keyword"),
167 "BUILTIN": idleConf.GetHighlight(theme, "builtin"),
168 "STRING": idleConf.GetHighlight(theme, "string"),
169 "DEFINITION": idleConf.GetHighlight(theme, "definition"),
170 "SYNC": {'background': None, 'foreground': None},
171 "TODO": {'background': None, 'foreground': None},
172 "ERROR": idleConf.GetHighlight(theme, "error"),
173 # "hit" is used by ReplaceDialog to mark matches. It shouldn't be changed by Colorizer, but
174 # that currently isn't technically possible. This should be moved elsewhere in the future
175 # when fixing the "hit" tag's visibility, or when the replace dialog is replaced with a
176 # non-modal alternative.
177 "hit": idleConf.GetHighlight(theme, "hit"),
178 }
179 if DEBUG: print('tagdefs', self.tagdefs)
181 def insert(self, index, chars, tags=None):
182 "Insert chars into widget at index and mark for colorizing."
183 index = self.index(index)
184 self.delegate.insert(index, chars, tags)
185 self.notify_range(index, index + "+%dc" % len(chars))
187 def delete(self, index1, index2=None):
188 "Delete chars between indexes and mark for colorizing."
189 index1 = self.index(index1)
190 self.delegate.delete(index1, index2)
191 self.notify_range(index1)
193 def notify_range(self, index1, index2=None):
194 "Mark text changes for processing and restart colorizing, if active."
195 self.tag_add("TODO", index1, index2)
196 if self.after_id:
197 if DEBUG: print("colorizing already scheduled")
198 return
199 if self.colorizing:
200 self.stop_colorizing = True
201 if DEBUG: print("stop colorizing")
202 if self.allow_colorizing:
203 if DEBUG: print("schedule colorizing")
204 self.after_id = self.after(1, self.recolorize)
205 return
207 def close(self):
208 if self.after_id:
209 after_id = self.after_id
210 self.after_id = None
211 if DEBUG: print("cancel scheduled recolorizer")
212 self.after_cancel(after_id)
213 self.allow_colorizing = False
214 self.stop_colorizing = True
216 def toggle_colorize_event(self, event=None):
217 """Toggle colorizing on and off.
219 When toggling off, if colorizing is scheduled or is in
220 process, it will be cancelled and/or stopped.
222 When toggling on, colorizing will be scheduled.
223 """
224 if self.after_id:
225 after_id = self.after_id
226 self.after_id = None
227 if DEBUG: print("cancel scheduled recolorizer")
228 self.after_cancel(after_id)
229 if self.allow_colorizing and self.colorizing:
230 if DEBUG: print("stop colorizing")
231 self.stop_colorizing = True
232 self.allow_colorizing = not self.allow_colorizing
233 if self.allow_colorizing and not self.colorizing:
234 self.after_id = self.after(1, self.recolorize)
235 if DEBUG:
236 print("auto colorizing turned",
237 "on" if self.allow_colorizing else "off")
238 return "break"
240 def recolorize(self):
241 """Timer event (every 1ms) to colorize text.
243 Colorizing is only attempted when the text widget exists,
244 when colorizing is toggled on, and when the colorizing
245 process is not already running.
247 After colorizing is complete, some cleanup is done to
248 make sure that all the text has been colorized.
249 """
250 self.after_id = None
251 if not self.delegate:
252 if DEBUG: print("no delegate")
253 return
254 if not self.allow_colorizing:
255 if DEBUG: print("auto colorizing is off")
256 return
257 if self.colorizing:
258 if DEBUG: print("already colorizing")
259 return
260 try:
261 self.stop_colorizing = False
262 self.colorizing = True
263 if DEBUG: print("colorizing...")
264 t0 = time.perf_counter()
265 self.recolorize_main()
266 t1 = time.perf_counter()
267 if DEBUG: print("%.3f seconds" % (t1-t0))
268 finally:
269 self.colorizing = False
270 if self.allow_colorizing and self.tag_nextrange("TODO", "1.0"):
271 if DEBUG: print("reschedule colorizing")
272 self.after_id = self.after(1, self.recolorize)
274 def recolorize_main(self):
275 "Evaluate text and apply colorizing tags."
276 next = "1.0"
277 while todo_tag_range := self.tag_nextrange("TODO", next):
278 self.tag_remove("SYNC", todo_tag_range[0], todo_tag_range[1])
279 sync_tag_range = self.tag_prevrange("SYNC", todo_tag_range[0])
280 head = sync_tag_range[1] if sync_tag_range else "1.0"
282 chars = ""
283 next = head
284 lines_to_get = 1
285 ok = False
286 while not ok:
287 mark = next
288 next = self.index(mark + "+%d lines linestart" %
289 lines_to_get)
290 lines_to_get = min(lines_to_get * 2, 100)
291 ok = "SYNC" in self.tag_names(next + "-1c")
292 line = self.get(mark, next)
293 ##print head, "get", mark, next, "->", repr(line)
294 if not line:
295 return
296 for tag in self.tagdefs:
297 self.tag_remove(tag, mark, next)
298 chars += line
299 self._add_tags_in_section(chars, head)
300 if "SYNC" in self.tag_names(next + "-1c"):
301 head = next
302 chars = ""
303 else:
304 ok = False
305 if not ok:
306 # We're in an inconsistent state, and the call to
307 # update may tell us to stop. It may also change
308 # the correct value for "next" (since this is a
309 # line.col string, not a true mark). So leave a
310 # crumb telling the next invocation to resume here
311 # in case update tells us to leave.
312 self.tag_add("TODO", next)
313 self.update()
314 if self.stop_colorizing:
315 if DEBUG: print("colorizing stopped")
316 return
318 def _add_tag(self, start, end, head, matched_group_name):
319 """Add a tag to a given range in the text widget.
321 This is a utility function, receiving the range as `start` and
322 `end` positions, each of which is a number of characters
323 relative to the given `head` index in the text widget.
325 The tag to add is determined by `matched_group_name`, which is
326 the name of a regular expression "named group" as matched by
327 by the relevant highlighting regexps.
328 """
329 tag = prog_group_name_to_tag.get(matched_group_name,
330 matched_group_name)
331 self.tag_add(tag,
332 f"{head}+{start:d}c",
333 f"{head}+{end:d}c")
335 def _add_tags_in_section(self, chars, head):
336 """Parse and add highlighting tags to a given part of the text.
338 `chars` is a string with the text to parse and to which
339 highlighting is to be applied.
341 `head` is the index in the text widget where the text is found.
342 """
343 for m in self.prog.finditer(chars):
344 for name, matched_text in matched_named_groups(m):
345 a, b = m.span(name)
346 self._add_tag(a, b, head, name)
347 if matched_text in ("def", "class"):
348 if m1 := self.idprog.match(chars, b):
349 a, b = m1.span(1)
350 self._add_tag(a, b, head, "DEFINITION")
352 def removecolors(self):
353 "Remove all colorizing tags."
354 for tag in self.tagdefs:
355 self.tag_remove(tag, "1.0", "end")
358def _color_delegator(parent): # htest #
359 from tkinter import Toplevel, Text
360 from idlelib.idle_test.test_colorizer import source
361 from idlelib.percolator import Percolator
363 top = Toplevel(parent)
364 top.title("Test ColorDelegator")
365 x, y = map(int, parent.geometry().split('+')[1:])
366 top.geometry("700x550+%d+%d" % (x + 20, y + 175))
368 text = Text(top, background="white")
369 text.pack(expand=1, fill="both")
370 text.insert("insert", source)
371 text.focus_set()
373 color_config(text)
374 p = Percolator(text)
375 d = ColorDelegator()
376 p.insertfilter(d)
379if __name__ == "__main__": 379 ↛ 380line 379 didn't jump to line 380, because the condition on line 379 was never true
380 from unittest import main
381 main('idlelib.idle_test.test_colorizer', verbosity=2, exit=False)
383 from idlelib.idle_test.htest import run
384 run(_color_delegator)