Coverage for colorizer.py: 20%

200 statements  

« prev     ^ index     » next       coverage.py v7.2.5, created at 2023-05-11 13:22 -0700

1import builtins 

2import keyword 

3import re 

4import time 

5 

6from idlelib.config import idleConf 

7from idlelib.delegator import Delegator 

8 

9DEBUG = False 

10 

11 

12def any(name, alternates): 

13 "Return a named group pattern matching list of alternates." 

14 return "(?P<%s>" % name + "|".join(alternates) + ")" 1acb

15 

16 

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

64 

65 

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} 

74 

75 

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) 

79 

80 

81def color_config(text): 

82 """Set color options of Text widget. 

83 

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 ) 

100 

101 

102class ColorDelegator(Delegator): 

103 """Delegator for syntax highlighting (text coloring). 

104 

105 Instance variables: 

106 delegate: Delegator below this one in the stack, meaning the 

107 one this one delegates to. 

108 

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

117 

118 def __init__(self): 

119 Delegator.__init__(self) 

120 self.init_state() 

121 self.prog = prog 

122 self.idprog = idprog 

123 self.LoadTagDefs() 

124 

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 

131 

132 def setdelegate(self, delegate): 

133 """Set the delegate for this instance. 

134 

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. 

140 

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 

154 

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') 

160 

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) 

180 

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)) 

186 

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) 

192 

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 

206 

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 

215 

216 def toggle_colorize_event(self, event=None): 

217 """Toggle colorizing on and off. 

218 

219 When toggling off, if colorizing is scheduled or is in 

220 process, it will be cancelled and/or stopped. 

221 

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" 

239 

240 def recolorize(self): 

241 """Timer event (every 1ms) to colorize text. 

242 

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. 

246 

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) 

273 

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" 

281 

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 

317 

318 def _add_tag(self, start, end, head, matched_group_name): 

319 """Add a tag to a given range in the text widget. 

320 

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. 

324 

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

334 

335 def _add_tags_in_section(self, chars, head): 

336 """Parse and add highlighting tags to a given part of the text. 

337 

338 `chars` is a string with the text to parse and to which 

339 highlighting is to be applied. 

340 

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

351 

352 def removecolors(self): 

353 "Remove all colorizing tags." 

354 for tag in self.tagdefs: 

355 self.tag_remove(tag, "1.0", "end") 

356 

357 

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 

362 

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)) 

367 

368 text = Text(top, background="white") 

369 text.pack(expand=1, fill="both") 

370 text.insert("insert", source) 

371 text.focus_set() 

372 

373 color_config(text) 

374 p = Percolator(text) 

375 d = ColorDelegator() 

376 p.insertfilter(d) 

377 

378 

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) 

382 

383 from idlelib.idle_test.htest import run 

384 run(_color_delegator)