1 """codecontext - display the block context above the edit window 2 3 Once code has scrolled off the top of a window, it can be difficult to 4 determine which block you are in. This extension implements a pane at the top 5 of each IDLE edit window which provides block structure hints. These hints are 6 the lines which contain the block opening keywords, e.g. 'if', for the 7 enclosing block. The number of hint lines is determined by the maxlines 8 variable in the codecontext section of config-extensions.def. Lines which do 9 not open blocks are not shown in the context hints pane. 10 11 """ 12 import re 13 from sys import maxsize as INFINITY 14 15 import tkinter 16 from tkinter.constants import TOP, X, SUNKEN 17 18 from idlelib.config import idleConf 19 20 BLOCKOPENERS = {"class", "def", "elif", "else", "except", "finally", "for", 21 "if", "try", "while", "with", "async"} 22 UPDATEINTERVAL = 100 # millisec 23 CONFIGUPDATEINTERVAL = 1000 # millisec 24 25 26 def 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 31 def 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 45 class CodeContext: 46 "Display block context above the edit window." 47 48 def __init__(self, editwin): 49 """Initialize settings for context block. 50 51 editwin is the Editor window for the context block. 52 self.text is the editor window text widget. 53 self.textfont is the editor window font. 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.textfont = self.text["font"] 69 self.contextcolors = CodeContext.colors 70 self.context = None 71 self.topvisible = 1 72 self.info = [(0, -1, "", False)] 73 # Start two update cycles, one for context lines, one for font changes. 74 self.t1 = self.text.after(UPDATEINTERVAL, self.timer_event) 75 self.t2 = self.text.after(CONFIGUPDATEINTERVAL, self.config_timer_event) 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", default=15) 82 cls.colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'context') 83 84 def __del__(self): 85 "Cancel scheduled events." 86 try: 87 self.text.after_cancel(self.t1) 88 self.text.after_cancel(self.t2) 89 except: 90 pass 91 92 def toggle_code_context_event(self, event=None): 93 """Toggle code context display. 94 95 If self.context doesn't exist, create it to match the size of the editor 96 window text (toggle on). If it does exist, destroy it (toggle off). 97 Return 'break' to complete the processing of the binding. 98 """ 99 if not self.context: 100 # Calculate the border width and horizontal padding required to 101 # align the context with the text in the main Text widget. 102 # 103 # All values are passed through getint(), since some 104 # values may be pixel objects, which can't simply be added to ints. 105 widgets = self.editwin.text, self.editwin.text_frame 106 # Calculate the required horizontal padding and border width. 107 padx = 0 108 border = 0 109 for widget in widgets: 110 padx += widget.tk.getint(widget.pack_info()['padx']) 111 padx += widget.tk.getint(widget.cget('padx')) 112 border += widget.tk.getint(widget.cget('border')) 113 self.context = tkinter.Text( 114 self.editwin.top, font=self.textfont, 115 bg=self.contextcolors['background'], 116 fg=self.contextcolors['foreground'], 117 height=1, 118 width=1, # Don't request more than we get. 119 padx=padx, border=border, relief=SUNKEN, state='disabled') 120 self.context.bind('<ButtonRelease-1>', self.jumptoline) 121 # Pack the context widget before and above the text_frame widget, 122 # thus ensuring that it will appear directly above text_frame. 123 self.context.pack(side=TOP, fill=X, expand=False, 124 before=self.editwin.text_frame) 125 menu_status = 'Hide' 126 else: 127 self.context.destroy() 128 self.context = None 129 menu_status = 'Show' 130 self.editwin.update_menu_label(menu='options', index='* Code Context', 131 label=f'{menu_status} Code Context') 132 return "break" 133 134 def get_context(self, new_topvisible, stopline=1, stopindent=0): 135 """Return a list of block line tuples and the 'last' indent. 136 137 The tuple fields are (linenum, indent, text, opener). 138 The list represents header lines from new_topvisible back to 139 stopline with successively shorter indents > stopindent. 140 The list is returned ordered by line number. 141 Last indent returned is the smallest indent observed. 142 """ 143 assert stopline > 0 144 lines = [] 145 # The indentation level we are currently in. 146 lastindent = INFINITY 147 # For a line to be interesting, it must begin with a block opening 148 # keyword, and have less indentation than lastindent. 149 for linenum in range(new_topvisible, stopline-1, -1): 150 codeline = self.text.get(f'{linenum}.0', f'{linenum}.end') 151 indent, text, opener = get_line_info(codeline) 152 if indent < lastindent: 153 lastindent = indent 154 if opener in ("else", "elif"): 155 # Also show the if statement. 156 lastindent += 1 157 if opener and linenum < new_topvisible and indent >= stopindent: 158 lines.append((linenum, indent, text, opener)) 159 if lastindent <= stopindent: 160 break 161 lines.reverse() 162 return lines, lastindent 163 164 def update_code_context(self): 165 """Update context information and lines visible in the context pane. 166 167 No update is done if the text hasn't been scrolled. If the text 168 was scrolled, the lines that should be shown in the context will 169 be retrieved and the context area will be updated with the code, 170 up to the number of maxlines. 171 """ 172 new_topvisible = int(self.text.index("@0,0").split('.')[0]) 173 if self.topvisible == new_topvisible: # Haven't scrolled. 174 return 175 if self.topvisible < new_topvisible: # Scroll down. 176 lines, lastindent = self.get_context(new_topvisible, 177 self.topvisible) 178 # Retain only context info applicable to the region 179 # between topvisible and new_topvisible. 180 while self.info[-1][1] >= lastindent: 181 del self.info[-1] 182 else: # self.topvisible > new_topvisible: # Scroll up. 183 stopindent = self.info[-1][1] + 1 184 # Retain only context info associated 185 # with lines above new_topvisible. 186 while self.info[-1][0] >= new_topvisible: 187 stopindent = self.info[-1][1] 188 del self.info[-1] 189 lines, lastindent = self.get_context(new_topvisible, 190 self.info[-1][0]+1, 191 stopindent) 192 self.info.extend(lines) 193 self.topvisible = new_topvisible 194 # Last context_depth context lines. 195 context_strings = [x[2] for x in self.info[-self.context_depth:]] 196 showfirst = 0 if context_strings[0] else 1 197 # Update widget. 198 self.context['height'] = len(context_strings) - showfirst 199 self.context['state'] = 'normal' 200 self.context.delete('1.0', 'end') 201 self.context.insert('end', '\n'.join(context_strings[showfirst:])) 202 self.context['state'] = 'disabled' 203 204 def jumptoline(self, event=None): 205 "Show clicked context line at top of editor." 206 lines = len(self.info) 207 if lines == 1: # No context lines are showing. 208 newtop = 1 209 else: 210 # Line number clicked. 211 contextline = int(float(self.context.index('insert'))) 212 # Lines not displayed due to maxlines. 213 offset = max(1, lines - self.context_depth) - 1 214 newtop = self.info[offset + contextline][0] 215 self.text.yview(f'{newtop}.0') 216 self.update_code_context() 217 218 def timer_event(self): 219 "Event on editor text widget triggered every UPDATEINTERVAL ms." 220 if self.context: 221 self.update_code_context() 222 self.t1 = self.text.after(UPDATEINTERVAL, self.timer_event) 223 224 def config_timer_event(self): 225 "Event on editor text widget triggered every CONFIGUPDATEINTERVAL ms." 226 newtextfont = self.text["font"] 227 if (self.context and (newtextfont != self.textfont or 228 CodeContext.colors != self.contextcolors)): 229 self.textfont = newtextfont 230 self.contextcolors = CodeContext.colors 231 self.context["font"] = self.textfont 232 self.context['background'] = self.contextcolors['background'] 233 self.context['foreground'] = self.contextcolors['foreground'] 234 self.t2 = self.text.after(CONFIGUPDATEINTERVAL, self.config_timer_event) 235 236 237 CodeContext.reload() 238 239 240 if __name__ == "__main__": 241 from unittest import main 242 main('idlelib.idle_test.test_codecontext', verbosity=2, exit=False) 243 244 # Add htest. 245