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