• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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