Home | History | Annotate | Download | only in idlelib
      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