1"""codecontext - display the block context above the edit window 2 3Once code has scrolled off the top of a window, it can be difficult to 4determine which block you are in. This extension implements a pane at the top 5of each IDLE edit window which provides block structure hints. These hints are 6the lines which contain the block opening keywords, e.g. 'if', for the 7enclosing block. The number of hint lines is determined by the maxlines 8variable in the codecontext section of config-extensions.def. Lines which do 9not open blocks are not shown in the context hints pane. 10 11For EditorWindows, <<toggle-code-context>> is bound to CodeContext(self). 12toggle_code_context_event. 13""" 14import re 15from sys import maxsize as INFINITY 16 17from tkinter import Frame, Text, TclError 18from tkinter.constants import NSEW, SUNKEN 19 20from idlelib.config import idleConf 21 22BLOCKOPENERS = {'class', 'def', 'if', 'elif', 'else', 'while', 'for', 23 'try', 'except', 'finally', 'with', 'async'} 24 25 26def get_spaces_firstword(codeline, c=re.compile(r"^(\s*)(\w*)")): 27 "Extract the beginning whitespace and first word from codeline." 28 return c.match(codeline).groups() 29 30 31def get_line_info(codeline): 32 """Return tuple of (line indent value, codeline, block start keyword). 33 34 The indentation of empty lines (or comment lines) is INFINITY. 35 If the line does not start a block, the keyword value is False. 36 """ 37 spaces, firstword = get_spaces_firstword(codeline) 38 indent = len(spaces) 39 if len(codeline) == indent or codeline[indent] == '#': 40 indent = INFINITY 41 opener = firstword in BLOCKOPENERS and firstword 42 return indent, codeline, opener 43 44 45class CodeContext: 46 "Display block context above the edit window." 47 UPDATEINTERVAL = 100 # millisec 48 49 def __init__(self, editwin): 50 """Initialize settings for context block. 51 52 editwin is the Editor window for the context block. 53 self.text is the editor window text widget. 54 55 self.context displays the code context text above the editor text. 56 Initially None, it is toggled via <<toggle-code-context>>. 57 self.topvisible is the number of the top text line displayed. 58 self.info is a list of (line number, indent level, line text, 59 block keyword) tuples for the block structure above topvisible. 60 self.info[0] is initialized with a 'dummy' line which 61 starts the toplevel 'block' of the module. 62 63 self.t1 and self.t2 are two timer events on the editor text widget to 64 monitor for changes to the context text or editor font. 65 """ 66 self.editwin = editwin 67 self.text = editwin.text 68 self._reset() 69 70 def _reset(self): 71 self.context = None 72 self.cell00 = None 73 self.t1 = None 74 self.topvisible = 1 75 self.info = [(0, -1, "", False)] 76 77 @classmethod 78 def reload(cls): 79 "Load class variables from config." 80 cls.context_depth = idleConf.GetOption("extensions", "CodeContext", 81 "maxlines", type="int", 82 default=15) 83 84 def __del__(self): 85 "Cancel scheduled events." 86 if self.t1 is not None: 87 try: 88 self.text.after_cancel(self.t1) 89 except TclError: # pragma: no cover 90 pass 91 self.t1 = None 92 93 def toggle_code_context_event(self, event=None): 94 """Toggle code context display. 95 96 If self.context doesn't exist, create it to match the size of the editor 97 window text (toggle on). If it does exist, destroy it (toggle off). 98 Return 'break' to complete the processing of the binding. 99 """ 100 if self.context is None: 101 # Calculate the border width and horizontal padding required to 102 # align the context with the text in the main Text widget. 103 # 104 # All values are passed through getint(), since some 105 # values may be pixel objects, which can't simply be added to ints. 106 widgets = self.editwin.text, self.editwin.text_frame 107 # Calculate the required horizontal padding and border width. 108 padx = 0 109 border = 0 110 for widget in widgets: 111 info = (widget.grid_info() 112 if widget is self.editwin.text 113 else widget.pack_info()) 114 padx += widget.tk.getint(info['padx']) 115 padx += widget.tk.getint(widget.cget('padx')) 116 border += widget.tk.getint(widget.cget('border')) 117 context = self.context = Text( 118 self.editwin.text_frame, 119 height=1, 120 width=1, # Don't request more than we get. 121 highlightthickness=0, 122 padx=padx, border=border, relief=SUNKEN, state='disabled') 123 self.update_font() 124 self.update_highlight_colors() 125 context.bind('<ButtonRelease-1>', self.jumptoline) 126 # Get the current context and initiate the recurring update event. 127 self.timer_event() 128 # Grid the context widget above the text widget. 129 context.grid(row=0, column=1, sticky=NSEW) 130 131 line_number_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 132 'linenumber') 133 self.cell00 = Frame(self.editwin.text_frame, 134 bg=line_number_colors['background']) 135 self.cell00.grid(row=0, column=0, sticky=NSEW) 136 menu_status = 'Hide' 137 else: 138 self.context.destroy() 139 self.context = None 140 self.cell00.destroy() 141 self.cell00 = None 142 self.text.after_cancel(self.t1) 143 self._reset() 144 menu_status = 'Show' 145 self.editwin.update_menu_label(menu='options', index='*ode*ontext', 146 label=f'{menu_status} Code Context') 147 return "break" 148 149 def get_context(self, new_topvisible, stopline=1, stopindent=0): 150 """Return a list of block line tuples and the 'last' indent. 151 152 The tuple fields are (linenum, indent, text, opener). 153 The list represents header lines from new_topvisible back to 154 stopline with successively shorter indents > stopindent. 155 The list is returned ordered by line number. 156 Last indent returned is the smallest indent observed. 157 """ 158 assert stopline > 0 159 lines = [] 160 # The indentation level we are currently in. 161 lastindent = INFINITY 162 # For a line to be interesting, it must begin with a block opening 163 # keyword, and have less indentation than lastindent. 164 for linenum in range(new_topvisible, stopline-1, -1): 165 codeline = self.text.get(f'{linenum}.0', f'{linenum}.end') 166 indent, text, opener = get_line_info(codeline) 167 if indent < lastindent: 168 lastindent = indent 169 if opener in ("else", "elif"): 170 # Also show the if statement. 171 lastindent += 1 172 if opener and linenum < new_topvisible and indent >= stopindent: 173 lines.append((linenum, indent, text, opener)) 174 if lastindent <= stopindent: 175 break 176 lines.reverse() 177 return lines, lastindent 178 179 def update_code_context(self): 180 """Update context information and lines visible in the context pane. 181 182 No update is done if the text hasn't been scrolled. If the text 183 was scrolled, the lines that should be shown in the context will 184 be retrieved and the context area will be updated with the code, 185 up to the number of maxlines. 186 """ 187 new_topvisible = self.editwin.getlineno("@0,0") 188 if self.topvisible == new_topvisible: # Haven't scrolled. 189 return 190 if self.topvisible < new_topvisible: # Scroll down. 191 lines, lastindent = self.get_context(new_topvisible, 192 self.topvisible) 193 # Retain only context info applicable to the region 194 # between topvisible and new_topvisible. 195 while self.info[-1][1] >= lastindent: 196 del self.info[-1] 197 else: # self.topvisible > new_topvisible: # Scroll up. 198 stopindent = self.info[-1][1] + 1 199 # Retain only context info associated 200 # with lines above new_topvisible. 201 while self.info[-1][0] >= new_topvisible: 202 stopindent = self.info[-1][1] 203 del self.info[-1] 204 lines, lastindent = self.get_context(new_topvisible, 205 self.info[-1][0]+1, 206 stopindent) 207 self.info.extend(lines) 208 self.topvisible = new_topvisible 209 # Last context_depth context lines. 210 context_strings = [x[2] for x in self.info[-self.context_depth:]] 211 showfirst = 0 if context_strings[0] else 1 212 # Update widget. 213 self.context['height'] = len(context_strings) - showfirst 214 self.context['state'] = 'normal' 215 self.context.delete('1.0', 'end') 216 self.context.insert('end', '\n'.join(context_strings[showfirst:])) 217 self.context['state'] = 'disabled' 218 219 def jumptoline(self, event=None): 220 """ Show clicked context line at top of editor. 221 222 If a selection was made, don't jump; allow copying. 223 If no visible context, show the top line of the file. 224 """ 225 try: 226 self.context.index("sel.first") 227 except TclError: 228 lines = len(self.info) 229 if lines == 1: # No context lines are showing. 230 newtop = 1 231 else: 232 # Line number clicked. 233 contextline = int(float(self.context.index('insert'))) 234 # Lines not displayed due to maxlines. 235 offset = max(1, lines - self.context_depth) - 1 236 newtop = self.info[offset + contextline][0] 237 self.text.yview(f'{newtop}.0') 238 self.update_code_context() 239 240 def timer_event(self): 241 "Event on editor text widget triggered every UPDATEINTERVAL ms." 242 if self.context is not None: 243 self.update_code_context() 244 self.t1 = self.text.after(self.UPDATEINTERVAL, self.timer_event) 245 246 def update_font(self): 247 if self.context is not None: 248 font = idleConf.GetFont(self.text, 'main', 'EditorWindow') 249 self.context['font'] = font 250 251 def update_highlight_colors(self): 252 if self.context is not None: 253 colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'context') 254 self.context['background'] = colors['background'] 255 self.context['foreground'] = colors['foreground'] 256 257 if self.cell00 is not None: 258 line_number_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 259 'linenumber') 260 self.cell00.config(bg=line_number_colors['background']) 261 262 263CodeContext.reload() 264 265 266if __name__ == "__main__": 267 from unittest import main 268 main('idlelib.idle_test.test_codecontext', verbosity=2, exit=False) 269 270 # Add htest. 271