17db96d56Sopenharmony_ci"""codecontext - display the block context above the edit window 27db96d56Sopenharmony_ci 37db96d56Sopenharmony_ciOnce code has scrolled off the top of a window, it can be difficult to 47db96d56Sopenharmony_cidetermine which block you are in. This extension implements a pane at the top 57db96d56Sopenharmony_ciof each IDLE edit window which provides block structure hints. These hints are 67db96d56Sopenharmony_cithe lines which contain the block opening keywords, e.g. 'if', for the 77db96d56Sopenharmony_cienclosing block. The number of hint lines is determined by the maxlines 87db96d56Sopenharmony_civariable in the codecontext section of config-extensions.def. Lines which do 97db96d56Sopenharmony_cinot open blocks are not shown in the context hints pane. 107db96d56Sopenharmony_ci 117db96d56Sopenharmony_ciFor EditorWindows, <<toggle-code-context>> is bound to CodeContext(self). 127db96d56Sopenharmony_citoggle_code_context_event. 137db96d56Sopenharmony_ci""" 147db96d56Sopenharmony_ciimport re 157db96d56Sopenharmony_cifrom sys import maxsize as INFINITY 167db96d56Sopenharmony_ci 177db96d56Sopenharmony_cifrom tkinter import Frame, Text, TclError 187db96d56Sopenharmony_cifrom tkinter.constants import NSEW, SUNKEN 197db96d56Sopenharmony_ci 207db96d56Sopenharmony_cifrom idlelib.config import idleConf 217db96d56Sopenharmony_ci 227db96d56Sopenharmony_ciBLOCKOPENERS = {'class', 'def', 'if', 'elif', 'else', 'while', 'for', 237db96d56Sopenharmony_ci 'try', 'except', 'finally', 'with', 'async'} 247db96d56Sopenharmony_ci 257db96d56Sopenharmony_ci 267db96d56Sopenharmony_cidef get_spaces_firstword(codeline, c=re.compile(r"^(\s*)(\w*)")): 277db96d56Sopenharmony_ci "Extract the beginning whitespace and first word from codeline." 287db96d56Sopenharmony_ci return c.match(codeline).groups() 297db96d56Sopenharmony_ci 307db96d56Sopenharmony_ci 317db96d56Sopenharmony_cidef get_line_info(codeline): 327db96d56Sopenharmony_ci """Return tuple of (line indent value, codeline, block start keyword). 337db96d56Sopenharmony_ci 347db96d56Sopenharmony_ci The indentation of empty lines (or comment lines) is INFINITY. 357db96d56Sopenharmony_ci If the line does not start a block, the keyword value is False. 367db96d56Sopenharmony_ci """ 377db96d56Sopenharmony_ci spaces, firstword = get_spaces_firstword(codeline) 387db96d56Sopenharmony_ci indent = len(spaces) 397db96d56Sopenharmony_ci if len(codeline) == indent or codeline[indent] == '#': 407db96d56Sopenharmony_ci indent = INFINITY 417db96d56Sopenharmony_ci opener = firstword in BLOCKOPENERS and firstword 427db96d56Sopenharmony_ci return indent, codeline, opener 437db96d56Sopenharmony_ci 447db96d56Sopenharmony_ci 457db96d56Sopenharmony_ciclass CodeContext: 467db96d56Sopenharmony_ci "Display block context above the edit window." 477db96d56Sopenharmony_ci UPDATEINTERVAL = 100 # millisec 487db96d56Sopenharmony_ci 497db96d56Sopenharmony_ci def __init__(self, editwin): 507db96d56Sopenharmony_ci """Initialize settings for context block. 517db96d56Sopenharmony_ci 527db96d56Sopenharmony_ci editwin is the Editor window for the context block. 537db96d56Sopenharmony_ci self.text is the editor window text widget. 547db96d56Sopenharmony_ci 557db96d56Sopenharmony_ci self.context displays the code context text above the editor text. 567db96d56Sopenharmony_ci Initially None, it is toggled via <<toggle-code-context>>. 577db96d56Sopenharmony_ci self.topvisible is the number of the top text line displayed. 587db96d56Sopenharmony_ci self.info is a list of (line number, indent level, line text, 597db96d56Sopenharmony_ci block keyword) tuples for the block structure above topvisible. 607db96d56Sopenharmony_ci self.info[0] is initialized with a 'dummy' line which 617db96d56Sopenharmony_ci starts the toplevel 'block' of the module. 627db96d56Sopenharmony_ci 637db96d56Sopenharmony_ci self.t1 and self.t2 are two timer events on the editor text widget to 647db96d56Sopenharmony_ci monitor for changes to the context text or editor font. 657db96d56Sopenharmony_ci """ 667db96d56Sopenharmony_ci self.editwin = editwin 677db96d56Sopenharmony_ci self.text = editwin.text 687db96d56Sopenharmony_ci self._reset() 697db96d56Sopenharmony_ci 707db96d56Sopenharmony_ci def _reset(self): 717db96d56Sopenharmony_ci self.context = None 727db96d56Sopenharmony_ci self.cell00 = None 737db96d56Sopenharmony_ci self.t1 = None 747db96d56Sopenharmony_ci self.topvisible = 1 757db96d56Sopenharmony_ci self.info = [(0, -1, "", False)] 767db96d56Sopenharmony_ci 777db96d56Sopenharmony_ci @classmethod 787db96d56Sopenharmony_ci def reload(cls): 797db96d56Sopenharmony_ci "Load class variables from config." 807db96d56Sopenharmony_ci cls.context_depth = idleConf.GetOption("extensions", "CodeContext", 817db96d56Sopenharmony_ci "maxlines", type="int", 827db96d56Sopenharmony_ci default=15) 837db96d56Sopenharmony_ci 847db96d56Sopenharmony_ci def __del__(self): 857db96d56Sopenharmony_ci "Cancel scheduled events." 867db96d56Sopenharmony_ci if self.t1 is not None: 877db96d56Sopenharmony_ci try: 887db96d56Sopenharmony_ci self.text.after_cancel(self.t1) 897db96d56Sopenharmony_ci except TclError: # pragma: no cover 907db96d56Sopenharmony_ci pass 917db96d56Sopenharmony_ci self.t1 = None 927db96d56Sopenharmony_ci 937db96d56Sopenharmony_ci def toggle_code_context_event(self, event=None): 947db96d56Sopenharmony_ci """Toggle code context display. 957db96d56Sopenharmony_ci 967db96d56Sopenharmony_ci If self.context doesn't exist, create it to match the size of the editor 977db96d56Sopenharmony_ci window text (toggle on). If it does exist, destroy it (toggle off). 987db96d56Sopenharmony_ci Return 'break' to complete the processing of the binding. 997db96d56Sopenharmony_ci """ 1007db96d56Sopenharmony_ci if self.context is None: 1017db96d56Sopenharmony_ci # Calculate the border width and horizontal padding required to 1027db96d56Sopenharmony_ci # align the context with the text in the main Text widget. 1037db96d56Sopenharmony_ci # 1047db96d56Sopenharmony_ci # All values are passed through getint(), since some 1057db96d56Sopenharmony_ci # values may be pixel objects, which can't simply be added to ints. 1067db96d56Sopenharmony_ci widgets = self.editwin.text, self.editwin.text_frame 1077db96d56Sopenharmony_ci # Calculate the required horizontal padding and border width. 1087db96d56Sopenharmony_ci padx = 0 1097db96d56Sopenharmony_ci border = 0 1107db96d56Sopenharmony_ci for widget in widgets: 1117db96d56Sopenharmony_ci info = (widget.grid_info() 1127db96d56Sopenharmony_ci if widget is self.editwin.text 1137db96d56Sopenharmony_ci else widget.pack_info()) 1147db96d56Sopenharmony_ci padx += widget.tk.getint(info['padx']) 1157db96d56Sopenharmony_ci padx += widget.tk.getint(widget.cget('padx')) 1167db96d56Sopenharmony_ci border += widget.tk.getint(widget.cget('border')) 1177db96d56Sopenharmony_ci context = self.context = Text( 1187db96d56Sopenharmony_ci self.editwin.text_frame, 1197db96d56Sopenharmony_ci height=1, 1207db96d56Sopenharmony_ci width=1, # Don't request more than we get. 1217db96d56Sopenharmony_ci highlightthickness=0, 1227db96d56Sopenharmony_ci padx=padx, border=border, relief=SUNKEN, state='disabled') 1237db96d56Sopenharmony_ci self.update_font() 1247db96d56Sopenharmony_ci self.update_highlight_colors() 1257db96d56Sopenharmony_ci context.bind('<ButtonRelease-1>', self.jumptoline) 1267db96d56Sopenharmony_ci # Get the current context and initiate the recurring update event. 1277db96d56Sopenharmony_ci self.timer_event() 1287db96d56Sopenharmony_ci # Grid the context widget above the text widget. 1297db96d56Sopenharmony_ci context.grid(row=0, column=1, sticky=NSEW) 1307db96d56Sopenharmony_ci 1317db96d56Sopenharmony_ci line_number_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 1327db96d56Sopenharmony_ci 'linenumber') 1337db96d56Sopenharmony_ci self.cell00 = Frame(self.editwin.text_frame, 1347db96d56Sopenharmony_ci bg=line_number_colors['background']) 1357db96d56Sopenharmony_ci self.cell00.grid(row=0, column=0, sticky=NSEW) 1367db96d56Sopenharmony_ci menu_status = 'Hide' 1377db96d56Sopenharmony_ci else: 1387db96d56Sopenharmony_ci self.context.destroy() 1397db96d56Sopenharmony_ci self.context = None 1407db96d56Sopenharmony_ci self.cell00.destroy() 1417db96d56Sopenharmony_ci self.cell00 = None 1427db96d56Sopenharmony_ci self.text.after_cancel(self.t1) 1437db96d56Sopenharmony_ci self._reset() 1447db96d56Sopenharmony_ci menu_status = 'Show' 1457db96d56Sopenharmony_ci self.editwin.update_menu_label(menu='options', index='*ode*ontext', 1467db96d56Sopenharmony_ci label=f'{menu_status} Code Context') 1477db96d56Sopenharmony_ci return "break" 1487db96d56Sopenharmony_ci 1497db96d56Sopenharmony_ci def get_context(self, new_topvisible, stopline=1, stopindent=0): 1507db96d56Sopenharmony_ci """Return a list of block line tuples and the 'last' indent. 1517db96d56Sopenharmony_ci 1527db96d56Sopenharmony_ci The tuple fields are (linenum, indent, text, opener). 1537db96d56Sopenharmony_ci The list represents header lines from new_topvisible back to 1547db96d56Sopenharmony_ci stopline with successively shorter indents > stopindent. 1557db96d56Sopenharmony_ci The list is returned ordered by line number. 1567db96d56Sopenharmony_ci Last indent returned is the smallest indent observed. 1577db96d56Sopenharmony_ci """ 1587db96d56Sopenharmony_ci assert stopline > 0 1597db96d56Sopenharmony_ci lines = [] 1607db96d56Sopenharmony_ci # The indentation level we are currently in. 1617db96d56Sopenharmony_ci lastindent = INFINITY 1627db96d56Sopenharmony_ci # For a line to be interesting, it must begin with a block opening 1637db96d56Sopenharmony_ci # keyword, and have less indentation than lastindent. 1647db96d56Sopenharmony_ci for linenum in range(new_topvisible, stopline-1, -1): 1657db96d56Sopenharmony_ci codeline = self.text.get(f'{linenum}.0', f'{linenum}.end') 1667db96d56Sopenharmony_ci indent, text, opener = get_line_info(codeline) 1677db96d56Sopenharmony_ci if indent < lastindent: 1687db96d56Sopenharmony_ci lastindent = indent 1697db96d56Sopenharmony_ci if opener in ("else", "elif"): 1707db96d56Sopenharmony_ci # Also show the if statement. 1717db96d56Sopenharmony_ci lastindent += 1 1727db96d56Sopenharmony_ci if opener and linenum < new_topvisible and indent >= stopindent: 1737db96d56Sopenharmony_ci lines.append((linenum, indent, text, opener)) 1747db96d56Sopenharmony_ci if lastindent <= stopindent: 1757db96d56Sopenharmony_ci break 1767db96d56Sopenharmony_ci lines.reverse() 1777db96d56Sopenharmony_ci return lines, lastindent 1787db96d56Sopenharmony_ci 1797db96d56Sopenharmony_ci def update_code_context(self): 1807db96d56Sopenharmony_ci """Update context information and lines visible in the context pane. 1817db96d56Sopenharmony_ci 1827db96d56Sopenharmony_ci No update is done if the text hasn't been scrolled. If the text 1837db96d56Sopenharmony_ci was scrolled, the lines that should be shown in the context will 1847db96d56Sopenharmony_ci be retrieved and the context area will be updated with the code, 1857db96d56Sopenharmony_ci up to the number of maxlines. 1867db96d56Sopenharmony_ci """ 1877db96d56Sopenharmony_ci new_topvisible = self.editwin.getlineno("@0,0") 1887db96d56Sopenharmony_ci if self.topvisible == new_topvisible: # Haven't scrolled. 1897db96d56Sopenharmony_ci return 1907db96d56Sopenharmony_ci if self.topvisible < new_topvisible: # Scroll down. 1917db96d56Sopenharmony_ci lines, lastindent = self.get_context(new_topvisible, 1927db96d56Sopenharmony_ci self.topvisible) 1937db96d56Sopenharmony_ci # Retain only context info applicable to the region 1947db96d56Sopenharmony_ci # between topvisible and new_topvisible. 1957db96d56Sopenharmony_ci while self.info[-1][1] >= lastindent: 1967db96d56Sopenharmony_ci del self.info[-1] 1977db96d56Sopenharmony_ci else: # self.topvisible > new_topvisible: # Scroll up. 1987db96d56Sopenharmony_ci stopindent = self.info[-1][1] + 1 1997db96d56Sopenharmony_ci # Retain only context info associated 2007db96d56Sopenharmony_ci # with lines above new_topvisible. 2017db96d56Sopenharmony_ci while self.info[-1][0] >= new_topvisible: 2027db96d56Sopenharmony_ci stopindent = self.info[-1][1] 2037db96d56Sopenharmony_ci del self.info[-1] 2047db96d56Sopenharmony_ci lines, lastindent = self.get_context(new_topvisible, 2057db96d56Sopenharmony_ci self.info[-1][0]+1, 2067db96d56Sopenharmony_ci stopindent) 2077db96d56Sopenharmony_ci self.info.extend(lines) 2087db96d56Sopenharmony_ci self.topvisible = new_topvisible 2097db96d56Sopenharmony_ci # Last context_depth context lines. 2107db96d56Sopenharmony_ci context_strings = [x[2] for x in self.info[-self.context_depth:]] 2117db96d56Sopenharmony_ci showfirst = 0 if context_strings[0] else 1 2127db96d56Sopenharmony_ci # Update widget. 2137db96d56Sopenharmony_ci self.context['height'] = len(context_strings) - showfirst 2147db96d56Sopenharmony_ci self.context['state'] = 'normal' 2157db96d56Sopenharmony_ci self.context.delete('1.0', 'end') 2167db96d56Sopenharmony_ci self.context.insert('end', '\n'.join(context_strings[showfirst:])) 2177db96d56Sopenharmony_ci self.context['state'] = 'disabled' 2187db96d56Sopenharmony_ci 2197db96d56Sopenharmony_ci def jumptoline(self, event=None): 2207db96d56Sopenharmony_ci """ Show clicked context line at top of editor. 2217db96d56Sopenharmony_ci 2227db96d56Sopenharmony_ci If a selection was made, don't jump; allow copying. 2237db96d56Sopenharmony_ci If no visible context, show the top line of the file. 2247db96d56Sopenharmony_ci """ 2257db96d56Sopenharmony_ci try: 2267db96d56Sopenharmony_ci self.context.index("sel.first") 2277db96d56Sopenharmony_ci except TclError: 2287db96d56Sopenharmony_ci lines = len(self.info) 2297db96d56Sopenharmony_ci if lines == 1: # No context lines are showing. 2307db96d56Sopenharmony_ci newtop = 1 2317db96d56Sopenharmony_ci else: 2327db96d56Sopenharmony_ci # Line number clicked. 2337db96d56Sopenharmony_ci contextline = int(float(self.context.index('insert'))) 2347db96d56Sopenharmony_ci # Lines not displayed due to maxlines. 2357db96d56Sopenharmony_ci offset = max(1, lines - self.context_depth) - 1 2367db96d56Sopenharmony_ci newtop = self.info[offset + contextline][0] 2377db96d56Sopenharmony_ci self.text.yview(f'{newtop}.0') 2387db96d56Sopenharmony_ci self.update_code_context() 2397db96d56Sopenharmony_ci 2407db96d56Sopenharmony_ci def timer_event(self): 2417db96d56Sopenharmony_ci "Event on editor text widget triggered every UPDATEINTERVAL ms." 2427db96d56Sopenharmony_ci if self.context is not None: 2437db96d56Sopenharmony_ci self.update_code_context() 2447db96d56Sopenharmony_ci self.t1 = self.text.after(self.UPDATEINTERVAL, self.timer_event) 2457db96d56Sopenharmony_ci 2467db96d56Sopenharmony_ci def update_font(self): 2477db96d56Sopenharmony_ci if self.context is not None: 2487db96d56Sopenharmony_ci font = idleConf.GetFont(self.text, 'main', 'EditorWindow') 2497db96d56Sopenharmony_ci self.context['font'] = font 2507db96d56Sopenharmony_ci 2517db96d56Sopenharmony_ci def update_highlight_colors(self): 2527db96d56Sopenharmony_ci if self.context is not None: 2537db96d56Sopenharmony_ci colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'context') 2547db96d56Sopenharmony_ci self.context['background'] = colors['background'] 2557db96d56Sopenharmony_ci self.context['foreground'] = colors['foreground'] 2567db96d56Sopenharmony_ci 2577db96d56Sopenharmony_ci if self.cell00 is not None: 2587db96d56Sopenharmony_ci line_number_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 2597db96d56Sopenharmony_ci 'linenumber') 2607db96d56Sopenharmony_ci self.cell00.config(bg=line_number_colors['background']) 2617db96d56Sopenharmony_ci 2627db96d56Sopenharmony_ci 2637db96d56Sopenharmony_ciCodeContext.reload() 2647db96d56Sopenharmony_ci 2657db96d56Sopenharmony_ci 2667db96d56Sopenharmony_ciif __name__ == "__main__": 2677db96d56Sopenharmony_ci from unittest import main 2687db96d56Sopenharmony_ci main('idlelib.idle_test.test_codecontext', verbosity=2, exit=False) 2697db96d56Sopenharmony_ci 2707db96d56Sopenharmony_ci # Add htest. 271