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