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
11For EditorWindows, <<toggle-code-context>> is bound to CodeContext(self).
12toggle_code_context_event.
13"""
14import re
15from sys import maxsize as INFINITY
16
17from tkinter import Frame, Text, TclError
18from tkinter.constants import NSEW, SUNKEN
19
20from idlelib.config import idleConf
21
22BLOCKOPENERS = {'class', 'def', 'if', 'elif', 'else', 'while', 'for',
23                 'try', 'except', 'finally', 'with', 'async'}
24
25
26def 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
31def 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
45class CodeContext:
46    "Display block context above the edit window."
47    UPDATEINTERVAL = 100  # millisec
48
49    def __init__(self, editwin):
50        """Initialize settings for context block.
51
52        editwin is the Editor window for the context block.
53        self.text is the editor window text widget.
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._reset()
69
70    def _reset(self):
71        self.context = None
72        self.cell00 = None
73        self.t1 = None
74        self.topvisible = 1
75        self.info = [(0, -1, "", False)]
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",
82                                               default=15)
83
84    def __del__(self):
85        "Cancel scheduled events."
86        if self.t1 is not None:
87            try:
88                self.text.after_cancel(self.t1)
89            except TclError:  # pragma: no cover
90                pass
91            self.t1 = None
92
93    def toggle_code_context_event(self, event=None):
94        """Toggle code context display.
95
96        If self.context doesn't exist, create it to match the size of the editor
97        window text (toggle on).  If it does exist, destroy it (toggle off).
98        Return 'break' to complete the processing of the binding.
99        """
100        if self.context is None:
101            # Calculate the border width and horizontal padding required to
102            # align the context with the text in the main Text widget.
103            #
104            # All values are passed through getint(), since some
105            # values may be pixel objects, which can't simply be added to ints.
106            widgets = self.editwin.text, self.editwin.text_frame
107            # Calculate the required horizontal padding and border width.
108            padx = 0
109            border = 0
110            for widget in widgets:
111                info = (widget.grid_info()
112                        if widget is self.editwin.text
113                        else widget.pack_info())
114                padx += widget.tk.getint(info['padx'])
115                padx += widget.tk.getint(widget.cget('padx'))
116                border += widget.tk.getint(widget.cget('border'))
117            context = self.context = Text(
118                self.editwin.text_frame,
119                height=1,
120                width=1,  # Don't request more than we get.
121                highlightthickness=0,
122                padx=padx, border=border, relief=SUNKEN, state='disabled')
123            self.update_font()
124            self.update_highlight_colors()
125            context.bind('<ButtonRelease-1>', self.jumptoline)
126            # Get the current context and initiate the recurring update event.
127            self.timer_event()
128            # Grid the context widget above the text widget.
129            context.grid(row=0, column=1, sticky=NSEW)
130
131            line_number_colors = idleConf.GetHighlight(idleConf.CurrentTheme(),
132                                                       'linenumber')
133            self.cell00 = Frame(self.editwin.text_frame,
134                                        bg=line_number_colors['background'])
135            self.cell00.grid(row=0, column=0, sticky=NSEW)
136            menu_status = 'Hide'
137        else:
138            self.context.destroy()
139            self.context = None
140            self.cell00.destroy()
141            self.cell00 = None
142            self.text.after_cancel(self.t1)
143            self._reset()
144            menu_status = 'Show'
145        self.editwin.update_menu_label(menu='options', index='*ode*ontext',
146                                       label=f'{menu_status} Code Context')
147        return "break"
148
149    def get_context(self, new_topvisible, stopline=1, stopindent=0):
150        """Return a list of block line tuples and the 'last' indent.
151
152        The tuple fields are (linenum, indent, text, opener).
153        The list represents header lines from new_topvisible back to
154        stopline with successively shorter indents > stopindent.
155        The list is returned ordered by line number.
156        Last indent returned is the smallest indent observed.
157        """
158        assert stopline > 0
159        lines = []
160        # The indentation level we are currently in.
161        lastindent = INFINITY
162        # For a line to be interesting, it must begin with a block opening
163        # keyword, and have less indentation than lastindent.
164        for linenum in range(new_topvisible, stopline-1, -1):
165            codeline = self.text.get(f'{linenum}.0', f'{linenum}.end')
166            indent, text, opener = get_line_info(codeline)
167            if indent < lastindent:
168                lastindent = indent
169                if opener in ("else", "elif"):
170                    # Also show the if statement.
171                    lastindent += 1
172                if opener and linenum < new_topvisible and indent >= stopindent:
173                    lines.append((linenum, indent, text, opener))
174                if lastindent <= stopindent:
175                    break
176        lines.reverse()
177        return lines, lastindent
178
179    def update_code_context(self):
180        """Update context information and lines visible in the context pane.
181
182        No update is done if the text hasn't been scrolled.  If the text
183        was scrolled, the lines that should be shown in the context will
184        be retrieved and the context area will be updated with the code,
185        up to the number of maxlines.
186        """
187        new_topvisible = self.editwin.getlineno("@0,0")
188        if self.topvisible == new_topvisible:      # Haven't scrolled.
189            return
190        if self.topvisible < new_topvisible:       # Scroll down.
191            lines, lastindent = self.get_context(new_topvisible,
192                                                 self.topvisible)
193            # Retain only context info applicable to the region
194            # between topvisible and new_topvisible.
195            while self.info[-1][1] >= lastindent:
196                del self.info[-1]
197        else:  # self.topvisible > new_topvisible: # Scroll up.
198            stopindent = self.info[-1][1] + 1
199            # Retain only context info associated
200            # with lines above new_topvisible.
201            while self.info[-1][0] >= new_topvisible:
202                stopindent = self.info[-1][1]
203                del self.info[-1]
204            lines, lastindent = self.get_context(new_topvisible,
205                                                 self.info[-1][0]+1,
206                                                 stopindent)
207        self.info.extend(lines)
208        self.topvisible = new_topvisible
209        # Last context_depth context lines.
210        context_strings = [x[2] for x in self.info[-self.context_depth:]]
211        showfirst = 0 if context_strings[0] else 1
212        # Update widget.
213        self.context['height'] = len(context_strings) - showfirst
214        self.context['state'] = 'normal'
215        self.context.delete('1.0', 'end')
216        self.context.insert('end', '\n'.join(context_strings[showfirst:]))
217        self.context['state'] = 'disabled'
218
219    def jumptoline(self, event=None):
220        """ Show clicked context line at top of editor.
221
222        If a selection was made, don't jump; allow copying.
223        If no visible context, show the top line of the file.
224        """
225        try:
226            self.context.index("sel.first")
227        except TclError:
228            lines = len(self.info)
229            if lines == 1:  # No context lines are showing.
230                newtop = 1
231            else:
232                # Line number clicked.
233                contextline = int(float(self.context.index('insert')))
234                # Lines not displayed due to maxlines.
235                offset = max(1, lines - self.context_depth) - 1
236                newtop = self.info[offset + contextline][0]
237            self.text.yview(f'{newtop}.0')
238            self.update_code_context()
239
240    def timer_event(self):
241        "Event on editor text widget triggered every UPDATEINTERVAL ms."
242        if self.context is not None:
243            self.update_code_context()
244            self.t1 = self.text.after(self.UPDATEINTERVAL, self.timer_event)
245
246    def update_font(self):
247        if self.context is not None:
248            font = idleConf.GetFont(self.text, 'main', 'EditorWindow')
249            self.context['font'] = font
250
251    def update_highlight_colors(self):
252        if self.context is not None:
253            colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'context')
254            self.context['background'] = colors['background']
255            self.context['foreground'] = colors['foreground']
256
257        if self.cell00 is not None:
258            line_number_colors = idleConf.GetHighlight(idleConf.CurrentTheme(),
259                                                       'linenumber')
260            self.cell00.config(bg=line_number_colors['background'])
261
262
263CodeContext.reload()
264
265
266if __name__ == "__main__":
267    from unittest import main
268    main('idlelib.idle_test.test_codecontext', verbosity=2, exit=False)
269
270    # Add htest.
271