1"""CodeContext - Extension to 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 numlines
8variable in the CodeContext section of config-extensions.def. Lines which do
9not open blocks are not shown in the context hints pane.
10
11"""
12import Tkinter
13from Tkconstants import TOP, LEFT, X, W, SUNKEN
14import re
15from sys import maxint as INFINITY
16from idlelib.configHandler import idleConf
17
18BLOCKOPENERS = set(["class", "def", "elif", "else", "except", "finally", "for",
19                    "if", "try", "while", "with"])
20UPDATEINTERVAL = 100 # millisec
21FONTUPDATEINTERVAL = 1000 # millisec
22
23getspacesfirstword =\
24                   lambda s, c=re.compile(r"^(\s*)(\w*)"): c.match(s).groups()
25
26class CodeContext:
27    menudefs = [('options', [('!Code Conte_xt', '<<toggle-code-context>>')])]
28    context_depth = idleConf.GetOption("extensions", "CodeContext",
29                                       "numlines", type="int", default=3)
30    bgcolor = idleConf.GetOption("extensions", "CodeContext",
31                                 "bgcolor", type="str", default="LightGray")
32    fgcolor = idleConf.GetOption("extensions", "CodeContext",
33                                 "fgcolor", type="str", default="Black")
34    def __init__(self, editwin):
35        self.editwin = editwin
36        self.text = editwin.text
37        self.textfont = self.text["font"]
38        self.label = None
39        # self.info is a list of (line number, indent level, line text, block
40        # keyword) tuples providing the block structure associated with
41        # self.topvisible (the linenumber of the line displayed at the top of
42        # the edit window). self.info[0] is initialized as a 'dummy' line which
43        # starts the toplevel 'block' of the module.
44        self.info = [(0, -1, "", False)]
45        self.topvisible = 1
46        visible = idleConf.GetOption("extensions", "CodeContext",
47                                     "visible", type="bool", default=False)
48        if visible:
49            self.toggle_code_context_event()
50            self.editwin.setvar('<<toggle-code-context>>', True)
51        # Start two update cycles, one for context lines, one for font changes.
52        self.text.after(UPDATEINTERVAL, self.timer_event)
53        self.text.after(FONTUPDATEINTERVAL, self.font_timer_event)
54
55    def toggle_code_context_event(self, event=None):
56        if not self.label:
57            # Calculate the border width and horizontal padding required to
58            # align the context with the text in the main Text widget.
59            #
60            # All values are passed through int(str(<value>)), since some
61            # values may be pixel objects, which can't simply be added to ints.
62            widgets = self.editwin.text, self.editwin.text_frame
63            # Calculate the required vertical padding
64            padx = 0
65            for widget in widgets:
66                padx += int(str( widget.pack_info()['padx'] ))
67                padx += int(str( widget.cget('padx') ))
68            # Calculate the required border width
69            border = 0
70            for widget in widgets:
71                border += int(str( widget.cget('border') ))
72            self.label = Tkinter.Label(self.editwin.top,
73                                       text="\n" * (self.context_depth - 1),
74                                       anchor=W, justify=LEFT,
75                                       font=self.textfont,
76                                       bg=self.bgcolor, fg=self.fgcolor,
77                                       width=1, #don't request more than we get
78                                       padx=padx, border=border,
79                                       relief=SUNKEN)
80            # Pack the label widget before and above the text_frame widget,
81            # thus ensuring that it will appear directly above text_frame
82            self.label.pack(side=TOP, fill=X, expand=False,
83                            before=self.editwin.text_frame)
84        else:
85            self.label.destroy()
86            self.label = None
87        idleConf.SetOption("extensions", "CodeContext", "visible",
88                           str(self.label is not None))
89        idleConf.SaveUserCfgFiles()
90
91    def get_line_info(self, linenum):
92        """Get the line indent value, text, and any block start keyword
93
94        If the line does not start a block, the keyword value is False.
95        The indentation of empty lines (or comment lines) is INFINITY.
96
97        """
98        text = self.text.get("%d.0" % linenum, "%d.end" % linenum)
99        spaces, firstword = getspacesfirstword(text)
100        opener = firstword in BLOCKOPENERS and firstword
101        if len(text) == len(spaces) or text[len(spaces)] == '#':
102            indent = INFINITY
103        else:
104            indent = len(spaces)
105        return indent, text, opener
106
107    def get_context(self, new_topvisible, stopline=1, stopindent=0):
108        """Get context lines, starting at new_topvisible and working backwards.
109
110        Stop when stopline or stopindent is reached. Return a tuple of context
111        data and the indent level at the top of the region inspected.
112
113        """
114        assert stopline > 0
115        lines = []
116        # The indentation level we are currently in:
117        lastindent = INFINITY
118        # For a line to be interesting, it must begin with a block opening
119        # keyword, and have less indentation than lastindent.
120        for linenum in xrange(new_topvisible, stopline-1, -1):
121            indent, text, opener = self.get_line_info(linenum)
122            if indent < lastindent:
123                lastindent = indent
124                if opener in ("else", "elif"):
125                    # We also show the if statement
126                    lastindent += 1
127                if opener and linenum < new_topvisible and indent >= stopindent:
128                    lines.append((linenum, indent, text, opener))
129                if lastindent <= stopindent:
130                    break
131        lines.reverse()
132        return lines, lastindent
133
134    def update_code_context(self):
135        """Update context information and lines visible in the context pane.
136
137        """
138        new_topvisible = int(self.text.index("@0,0").split('.')[0])
139        if self.topvisible == new_topvisible:      # haven't scrolled
140            return
141        if self.topvisible < new_topvisible:       # scroll down
142            lines, lastindent = self.get_context(new_topvisible,
143                                                 self.topvisible)
144            # retain only context info applicable to the region
145            # between topvisible and new_topvisible:
146            while self.info[-1][1] >= lastindent:
147                del self.info[-1]
148        elif self.topvisible > new_topvisible:     # scroll up
149            stopindent = self.info[-1][1] + 1
150            # retain only context info associated
151            # with lines above new_topvisible:
152            while self.info[-1][0] >= new_topvisible:
153                stopindent = self.info[-1][1]
154                del self.info[-1]
155            lines, lastindent = self.get_context(new_topvisible,
156                                                 self.info[-1][0]+1,
157                                                 stopindent)
158        self.info.extend(lines)
159        self.topvisible = new_topvisible
160        # empty lines in context pane:
161        context_strings = [""] * max(0, self.context_depth - len(self.info))
162        # followed by the context hint lines:
163        context_strings += [x[2] for x in self.info[-self.context_depth:]]
164        self.label["text"] = '\n'.join(context_strings)
165
166    def timer_event(self):
167        if self.label:
168            self.update_code_context()
169        self.text.after(UPDATEINTERVAL, self.timer_event)
170
171    def font_timer_event(self):
172        newtextfont = self.text["font"]
173        if self.label and newtextfont != self.textfont:
174            self.textfont = newtextfont
175            self.label["font"] = self.textfont
176        self.text.after(FONTUPDATEINTERVAL, self.font_timer_event)
177