[Python-checkins] [3.7] bpo-17535: IDLE editor line numbers (GH-14030)

Tal Einat webhook-mailer at python.org
Tue Jul 23 09:27:22 EDT 2019


https://github.com/python/cpython/commit/e9ec1663d8fa0b3829add14e784107482af8dacb
commit: e9ec1663d8fa0b3829add14e784107482af8dacb
branch: 3.7
author: Tal Einat <taleinat at gmail.com>
committer: GitHub <noreply at github.com>
date: 2019-07-23T16:27:04+03:00
summary:

[3.7] bpo-17535: IDLE editor line numbers (GH-14030)

(cherry picked from commit 7123ea009b0b004062d91f69859bddf422c34ab4)

files:
A Lib/idlelib/idle_test/test_sidebar.py
A Lib/idlelib/sidebar.py
A Misc/NEWS.d/next/IDLE/2019-06-13-01-07-20.bpo-17535.K8i2St.rst
M Doc/library/idle.rst
M Doc/whatsnew/3.7.rst
M Lib/idlelib/codecontext.py
M Lib/idlelib/config-highlight.def
M Lib/idlelib/config-main.def
M Lib/idlelib/config.py
M Lib/idlelib/configdialog.py
M Lib/idlelib/editor.py
M Lib/idlelib/help.html
M Lib/idlelib/idle_test/htest.py
M Lib/idlelib/idle_test/test_codecontext.py
M Lib/idlelib/mainmenu.py
M Lib/idlelib/outwin.py
M Lib/idlelib/pyshell.py

diff --git a/Doc/library/idle.rst b/Doc/library/idle.rst
index de58f266bf5e..5975e3bb5806 100644
--- a/Doc/library/idle.rst
+++ b/Doc/library/idle.rst
@@ -290,22 +290,31 @@ Options menu (Shell and Editor)
 Configure IDLE
    Open a configuration dialog and change preferences for the following:
    fonts, indentation, keybindings, text color themes, startup windows and
-   size, additional help sources, and extensions.  On macOS,  open the
+   size, additional help sources, and extensions.  On macOS, open the
    configuration dialog by selecting Preferences in the application
-   menu. For more, see
+   menu. For more details, see
    :ref:`Setting preferences <preferences>` under Help and preferences.
 
+   Most configuration options apply to all windows or all future windows.
+   The option items below only apply to the active window.
+
 Show/Hide Code Context (Editor Window only)
    Open a pane at the top of the edit window which shows the block context
    of the code which has scrolled above the top of the window.  See
-   :ref:`Code Context <code-context>` in the Editing and Navigation section below.
+   :ref:`Code Context <code-context>` in the Editing and Navigation section
+   below.
+
+Show/Hide Line Numbers (Editor Window only)
+   Open a column to the left of the edit window which shows the number
+   of each line of text.  The default is off, which may be changed in the
+   preferences (see :ref:`Setting preferences <preferences>`).
 
 Zoom/Restore Height
    Toggles the window between normal size and maximum height. The initial size
    defaults to 40 lines by 80 chars unless changed on the General tab of the
    Configure IDLE dialog.  The maximum height for a screen is determined by
    momentarily maximizing a window the first time one is zoomed on the screen.
-   Changing screen settings may invalidate the saved height.  This toogle has
+   Changing screen settings may invalidate the saved height.  This toggle has
    no effect when a window is maximized.
 
 Window menu (Shell and Editor)
diff --git a/Doc/whatsnew/3.7.rst b/Doc/whatsnew/3.7.rst
index e78af225d385..934a5693ebf2 100644
--- a/Doc/whatsnew/3.7.rst
+++ b/Doc/whatsnew/3.7.rst
@@ -1017,6 +1017,13 @@ by right-clicking the button.  (Contributed by Tal Einat in :issue:`1529353`.)
 
 The changes above have been backported to 3.6 maintenance releases.
 
+New in 3.7.5:
+
+Add optional line numbers for IDLE editor windows. Windows
+open without line numbers unless set otherwise in the General
+tab of the configuration dialog.
+(Contributed by Tal Einat and Saimadhav Heblikar in :issue:`17535`.)
+
 
 importlib
 ---------
diff --git a/Lib/idlelib/codecontext.py b/Lib/idlelib/codecontext.py
index 3103391cfe58..4ce98136fe41 100644
--- a/Lib/idlelib/codecontext.py
+++ b/Lib/idlelib/codecontext.py
@@ -13,7 +13,7 @@
 from sys import maxsize as INFINITY
 
 import tkinter
-from tkinter.constants import TOP, X, SUNKEN
+from tkinter.constants import NSEW, SUNKEN
 
 from idlelib.config import idleConf
 
@@ -67,6 +67,7 @@ def __init__(self, editwin):
 
     def _reset(self):
         self.context = None
+        self.cell00 = None
         self.t1 = None
         self.topvisible = 1
         self.info = [(0, -1, "", False)]
@@ -105,25 +106,37 @@ def toggle_code_context_event(self, event=None):
             padx = 0
             border = 0
             for widget in widgets:
-                padx += widget.tk.getint(widget.pack_info()['padx'])
+                info = (widget.grid_info()
+                        if widget is self.editwin.text
+                        else widget.pack_info())
+                padx += widget.tk.getint(info['padx'])
                 padx += widget.tk.getint(widget.cget('padx'))
                 border += widget.tk.getint(widget.cget('border'))
             self.context = tkinter.Text(
-                self.editwin.top, font=self.text['font'],
+                self.editwin.text_frame,
                 height=1,
                 width=1,  # Don't request more than we get.
+                highlightthickness=0,
                 padx=padx, border=border, relief=SUNKEN, state='disabled')
+            self.update_font()
             self.update_highlight_colors()
             self.context.bind('<ButtonRelease-1>', self.jumptoline)
             # Get the current context and initiate the recurring update event.
             self.timer_event()
-            # Pack the context widget before and above the text_frame widget,
-            # thus ensuring that it will appear directly above text_frame.
-            self.context.pack(side=TOP, fill=X, expand=False,
-                              before=self.editwin.text_frame)
+            # Grid the context widget above the text widget.
+            self.context.grid(row=0, column=1, sticky=NSEW)
+
+            line_number_colors = idleConf.GetHighlight(idleConf.CurrentTheme(),
+                                                       'linenumber')
+            self.cell00 = tkinter.Frame(self.editwin.text_frame,
+                                        bg=line_number_colors['background'])
+            self.cell00.grid(row=0, column=0, sticky=NSEW)
             menu_status = 'Hide'
         else:
             self.context.destroy()
+            self.context = None
+            self.cell00.destroy()
+            self.cell00 = None
             self.text.after_cancel(self.t1)
             self._reset()
             menu_status = 'Show'
@@ -221,8 +234,9 @@ def timer_event(self):
             self.update_code_context()
             self.t1 = self.text.after(self.UPDATEINTERVAL, self.timer_event)
 
-    def update_font(self, font):
+    def update_font(self):
         if self.context is not None:
+            font = idleConf.GetFont(self.text, 'main', 'EditorWindow')
             self.context['font'] = font
 
     def update_highlight_colors(self):
@@ -231,6 +245,11 @@ def update_highlight_colors(self):
             self.context['background'] = colors['background']
             self.context['foreground'] = colors['foreground']
 
+        if self.cell00 is not None:
+            line_number_colors = idleConf.GetHighlight(idleConf.CurrentTheme(),
+                                                       'linenumber')
+            self.cell00.config(bg=line_number_colors['background'])
+
 
 CodeContext.reload()
 
diff --git a/Lib/idlelib/config-highlight.def b/Lib/idlelib/config-highlight.def
index aaa2b57a7f6d..a7b0433831c5 100644
--- a/Lib/idlelib/config-highlight.def
+++ b/Lib/idlelib/config-highlight.def
@@ -22,6 +22,10 @@ hit-foreground= #ffffff
 hit-background= #000000
 error-foreground= #000000
 error-background= #ff7777
+context-foreground= #000000
+context-background= lightgray
+linenumber-foreground= gray
+linenumber-background= #ffffff
 #cursor (only foreground can be set, restart IDLE)
 cursor-foreground= black
 #shell window
@@ -31,8 +35,6 @@ stderr-foreground= red
 stderr-background= #ffffff
 console-foreground= #770000
 console-background= #ffffff
-context-foreground= #000000
-context-background= lightgray
 
 [IDLE New]
 normal-foreground= #000000
@@ -55,6 +57,10 @@ hit-foreground= #ffffff
 hit-background= #000000
 error-foreground= #000000
 error-background= #ff7777
+context-foreground= #000000
+context-background= lightgray
+linenumber-foreground= gray
+linenumber-background= #ffffff
 #cursor (only foreground can be set, restart IDLE)
 cursor-foreground= black
 #shell window
@@ -64,8 +70,6 @@ stderr-foreground= red
 stderr-background= #ffffff
 console-foreground= #770000
 console-background= #ffffff
-context-foreground= #000000
-context-background= lightgray
 
 [IDLE Dark]
 comment-foreground = #dd0000
@@ -97,3 +101,5 @@ comment-background = #002240
 break-foreground = #FFFFFF
 context-foreground= #ffffff
 context-background= #454545
+linenumber-foreground= gray
+linenumber-background= #002240
diff --git a/Lib/idlelib/config-main.def b/Lib/idlelib/config-main.def
index 06e3c5adb0e3..b2be6250d021 100644
--- a/Lib/idlelib/config-main.def
+++ b/Lib/idlelib/config-main.def
@@ -36,7 +36,7 @@
 # Additional help sources are listed in the [HelpFiles] section below
 # and should be viewable by a web browser (or the Windows Help viewer in
 # the case of .chm files). These sources will be listed on the Help
-# menu.  The pattern, and two examples, are
+# menu.  The pattern, and two examples, are:
 #
 # <sequence_number = menu item;/path/to/help/source>
 # 1 = IDLE;C:/Programs/Python36/Lib/idlelib/help.html
@@ -46,7 +46,7 @@
 # platform specific because of path separators, drive specs etc.
 #
 # The default files should not be edited except to add new sections to
-# config-extensions.def for added extensions .  The user files should be
+# config-extensions.def for added extensions.  The user files should be
 # modified through the Settings dialog.
 
 [General]
@@ -65,6 +65,7 @@ font= TkFixedFont
 font-size= 10
 font-bold= 0
 encoding= none
+line-numbers-default= 0
 
 [PyShell]
 auto-squeeze-min-lines= 50
diff --git a/Lib/idlelib/config.py b/Lib/idlelib/config.py
index 0c55c9a7d75d..683b000a488b 100644
--- a/Lib/idlelib/config.py
+++ b/Lib/idlelib/config.py
@@ -319,6 +319,10 @@ def GetThemeDict(self, type, themeName):
                 'hit-background':'#000000',
                 'error-foreground':'#ffffff',
                 'error-background':'#000000',
+                'context-foreground':'#000000',
+                'context-background':'#ffffff',
+                'linenumber-foreground':'#000000',
+                'linenumber-background':'#ffffff',
                 #cursor (only foreground can be set)
                 'cursor-foreground':'#000000',
                 #shell window
@@ -328,11 +332,11 @@ def GetThemeDict(self, type, themeName):
                 'stderr-background':'#ffffff',
                 'console-foreground':'#000000',
                 'console-background':'#ffffff',
-                'context-foreground':'#000000',
-                'context-background':'#ffffff',
                 }
         for element in theme:
-            if not cfgParser.has_option(themeName, element):
+            if not (cfgParser.has_option(themeName, element) or
+                    # Skip warning for new elements.
+                    element.startswith(('context-', 'linenumber-'))):
                 # Print warning that will return a default color
                 warning = ('\n Warning: config.IdleConf.GetThemeDict'
                            ' -\n problem retrieving theme element %r'
diff --git a/Lib/idlelib/configdialog.py b/Lib/idlelib/configdialog.py
index 6b0ecc9d68e5..217f8fd0a5fb 100644
--- a/Lib/idlelib/configdialog.py
+++ b/Lib/idlelib/configdialog.py
@@ -819,6 +819,7 @@ def create_page_highlight(self):
             'Shell Error Text': ('error', '12'),
             'Shell Stdout Text': ('stdout', '13'),
             'Shell Stderr Text': ('stderr', '14'),
+            'Line Number': ('linenumber', '16'),
             }
         self.builtin_name = tracers.add(
                 StringVar(self), self.var_changed_builtin_name)
@@ -866,6 +867,11 @@ def create_page_highlight(self):
             ('stderr', 'stderr'), ('\n\n', 'normal'))
         for texttag in text_and_tags:
             text.insert(END, texttag[0], texttag[1])
+        n_lines = len(text.get('1.0', END).splitlines())
+        for lineno in range(1, n_lines + 1):
+            text.insert(f'{lineno}.0',
+                        f'{lineno:{len(str(n_lines))}d} ',
+                        'linenumber')
         for element in self.theme_elements:
             def tem(event, elem=element):
                 # event.widget.winfo_top_level().highlight_target.set(elem)
@@ -1827,6 +1833,9 @@ def create_page_general(self):
                 frame_format: Frame
                     format_width_title: Label
                     (*)format_width_int: Entry - format_width
+                frame_line_numbers_default: Frame
+                    line_numbers_default_title: Label
+                    (*)line_numbers_default_bool: Checkbutton - line_numbers_default
                 frame_context: Frame
                     context_title: Label
                     (*)context_int: Entry - context_lines
@@ -1866,6 +1875,9 @@ def create_page_general(self):
                 IntVar(self), ('main', 'General', 'autosave'))
         self.format_width = tracers.add(
                 StringVar(self), ('extensions', 'FormatParagraph', 'max-width'))
+        self.line_numbers_default = tracers.add(
+                BooleanVar(self),
+                ('main', 'EditorWindow', 'line-numbers-default'))
         self.context_lines = tracers.add(
                 StringVar(self), ('extensions', 'CodeContext', 'maxlines'))
 
@@ -1944,6 +1956,14 @@ def create_page_general(self):
                 validatecommand=self.digits_only, validate='key',
         )
 
+        frame_line_numbers_default = Frame(frame_editor, borderwidth=0)
+        line_numbers_default_title = Label(
+            frame_line_numbers_default, text='Show line numbers in new windows')
+        self.line_numbers_default_bool = Checkbutton(
+                frame_line_numbers_default,
+                variable=self.line_numbers_default,
+                width=1)
+
         frame_context = Frame(frame_editor, borderwidth=0)
         context_title = Label(frame_context, text='Max Context Lines :')
         self.context_int = Entry(
@@ -2021,6 +2041,10 @@ def create_page_general(self):
         frame_format.pack(side=TOP, padx=5, pady=0, fill=X)
         format_width_title.pack(side=LEFT, anchor=W, padx=5, pady=5)
         self.format_width_int.pack(side=TOP, padx=10, pady=5)
+        # frame_line_numbers_default.
+        frame_line_numbers_default.pack(side=TOP, padx=5, pady=0, fill=X)
+        line_numbers_default_title.pack(side=LEFT, anchor=W, padx=5, pady=5)
+        self.line_numbers_default_bool.pack(side=LEFT, padx=5, pady=5)
         # frame_context.
         frame_context.pack(side=TOP, padx=5, pady=0, fill=X)
         context_title.pack(side=LEFT, anchor=W, padx=5, pady=5)
@@ -2063,6 +2087,8 @@ def load_general_cfg(self):
                 'main', 'General', 'autosave', default=0, type='bool'))
         self.format_width.set(idleConf.GetOption(
                 'extensions', 'FormatParagraph', 'max-width', type='int'))
+        self.line_numbers_default.set(idleConf.GetOption(
+                'main', 'EditorWindow', 'line-numbers-default', type='bool'))
         self.context_lines.set(idleConf.GetOption(
                 'extensions', 'CodeContext', 'maxlines', type='int'))
 
diff --git a/Lib/idlelib/editor.py b/Lib/idlelib/editor.py
index 24b1ffc67975..497ee12f1814 100644
--- a/Lib/idlelib/editor.py
+++ b/Lib/idlelib/editor.py
@@ -53,6 +53,7 @@ class EditorWindow(object):
     from idlelib.autoexpand import AutoExpand
     from idlelib.calltip import Calltip
     from idlelib.codecontext import CodeContext
+    from idlelib.sidebar import LineNumbers
     from idlelib.format import FormatParagraph, FormatRegion, Indents, Rstrip
     from idlelib.parenmatch import ParenMatch
     from idlelib.squeezer import Squeezer
@@ -61,7 +62,8 @@ class EditorWindow(object):
     filesystemencoding = sys.getfilesystemencoding()  # for file names
     help_url = None
 
-    allow_codecontext = True
+    allow_code_context = True
+    allow_line_numbers = True
 
     def __init__(self, flist=None, filename=None, key=None, root=None):
         # Delay import: runscript imports pyshell imports EditorWindow.
@@ -198,12 +200,14 @@ def __init__(self, flist=None, filename=None, key=None, root=None):
             text.bind("<<open-turtle-demo>>", self.open_turtle_demo)
 
         self.set_status_bar()
+        text_frame.pack(side=LEFT, fill=BOTH, expand=1)
+        text_frame.rowconfigure(1, weight=1)
+        text_frame.columnconfigure(1, weight=1)
         vbar['command'] = self.handle_yview
-        vbar.pack(side=RIGHT, fill=Y)
+        vbar.grid(row=1, column=2, sticky=NSEW)
         text['yscrollcommand'] = vbar.set
         text['font'] = idleConf.GetFont(self.root, 'main', 'EditorWindow')
-        text_frame.pack(side=LEFT, fill=BOTH, expand=1)
-        text.pack(side=TOP, fill=BOTH, expand=1)
+        text.grid(row=1, column=1, sticky=NSEW)
         text.focus_set()
 
         # usetabs true  -> literal tab characters are used by indent and
@@ -250,7 +254,8 @@ def __init__(self, flist=None, filename=None, key=None, root=None):
         self.good_load = False
         self.set_indentation_params(False)
         self.color = None # initialized below in self.ResetColorizer
-        self.codecontext = None
+        self.code_context = None # optionally initialized later below
+        self.line_numbers = None # optionally initialized later below
         if filename:
             if os.path.exists(filename) and not os.path.isdir(filename):
                 if io.loadfile(filename):
@@ -316,10 +321,20 @@ def __init__(self, flist=None, filename=None, key=None, root=None):
         text.bind("<<refresh-calltip>>", ctip.refresh_calltip_event)
         text.bind("<<force-open-calltip>>", ctip.force_open_calltip_event)
         text.bind("<<zoom-height>>", self.ZoomHeight(self).zoom_height_event)
-        if self.allow_codecontext:
-            self.codecontext = self.CodeContext(self)
+        if self.allow_code_context:
+            self.code_context = self.CodeContext(self)
             text.bind("<<toggle-code-context>>",
-                      self.codecontext.toggle_code_context_event)
+                      self.code_context.toggle_code_context_event)
+        else:
+            self.update_menu_state('options', '*Code Context', 'disabled')
+        if self.allow_line_numbers:
+            self.line_numbers = self.LineNumbers(self)
+            if idleConf.GetOption('main', 'EditorWindow',
+                                  'line-numbers-default', type='bool'):
+                self.toggle_line_numbers_event()
+            text.bind("<<toggle-line-numbers>>", self.toggle_line_numbers_event)
+        else:
+            self.update_menu_state('options', '*Line Numbers', 'disabled')
 
     def _filename_to_unicode(self, filename):
         """Return filename as BMP unicode so displayable in Tk."""
@@ -779,8 +794,11 @@ def ResetColorizer(self):
         self._addcolorizer()
         EditorWindow.color_config(self.text)
 
-        if self.codecontext is not None:
-            self.codecontext.update_highlight_colors()
+        if self.code_context is not None:
+            self.code_context.update_highlight_colors()
+
+        if self.line_numbers is not None:
+            self.line_numbers.update_colors()
 
     IDENTCHARS = string.ascii_letters + string.digits + "_"
 
@@ -799,11 +817,16 @@ def ResetFont(self):
         "Update the text widgets' font if it is changed"
         # Called from configdialog.py
 
-        new_font = idleConf.GetFont(self.root, 'main', 'EditorWindow')
         # Update the code context widget first, since its height affects
         # the height of the text widget.  This avoids double re-rendering.
-        if self.codecontext is not None:
-            self.codecontext.update_font(new_font)
+        if self.code_context is not None:
+            self.code_context.update_font()
+        # Next, update the line numbers widget, since its width affects
+        # the width of the text widget.
+        if self.line_numbers is not None:
+            self.line_numbers.update_font()
+        # Finally, update the main text widget.
+        new_font = idleConf.GetFont(self.root, 'main', 'EditorWindow')
         self.text['font'] = new_font
 
     def RemoveKeybindings(self):
@@ -1467,6 +1490,19 @@ def guess_indent(self):
             indentsmall = indentlarge = 0
         return indentlarge - indentsmall
 
+    def toggle_line_numbers_event(self, event=None):
+        if self.line_numbers is None:
+            return
+
+        if self.line_numbers.is_shown:
+            self.line_numbers.hide_sidebar()
+            menu_label = "Show"
+        else:
+            self.line_numbers.show_sidebar()
+            menu_label = "Hide"
+        self.update_menu_label(menu='options', index='*Line Numbers',
+                               label=f'{menu_label} Line Numbers')
+
 # "line.col" -> line, as an int
 def index2line(index):
     return int(float(index))
diff --git a/Lib/idlelib/help.html b/Lib/idlelib/help.html
index cee6887df68f..49068df7a8b9 100644
--- a/Lib/idlelib/help.html
+++ b/Lib/idlelib/help.html
@@ -271,10 +271,15 @@ <h3>Options menu (Shell and Editor)<a class="headerlink" href="#options-menu-she
 size, additional help sources, and extensions.  On macOS,  open the
 configuration dialog by selecting Preferences in the application
 menu. For more, see
-<a class="reference internal" href="#preferences"><span class="std std-ref">Setting preferences</span></a> under Help and preferences.</dd>
+<a class="reference internal" href="#preferences"><span class="std std-ref">Setting preferences</span></a> under Help and preferences.
+Most configuration options apply to all windows or all future windows.
+The option items below only apply to the active window.</dd>
 <dt>Show/Hide Code Context (Editor Window only)</dt><dd>Open a pane at the top of the edit window which shows the block context
 of the code which has scrolled above the top of the window.  See
 <a class="reference internal" href="#code-context"><span class="std std-ref">Code Context</span></a> in the Editing and Navigation section below.</dd>
+<dt>Line Numbers (Editor Window only)</dt><dd>Open a column to the left of the edit window which shows the linenumber
+of each line of text.  The default is off unless configured on
+(see <a class="reference internal" href="#preferences"><span class="std std-ref">Setting preferences</span></a>).</dd>
 <dt>Zoom/Restore Height</dt><dd>Toggles the window between normal size and maximum height. The initial size
 defaults to 40 lines by 80 chars unless changed on the General tab of the
 Configure IDLE dialog.  The maximum height for a screen is determined by
@@ -607,7 +612,7 @@ <h3>Running user code<a class="headerlink" href="#running-user-code" title="Perm
 will then be attached to that window for input and output.</p>
 <p>The IDLE code running in the execution process adds frames to the call stack
 that would not be there otherwise.  IDLE wraps <code class="docutils literal notranslate"><span class="pre">sys.getrecursionlimit</span></code> and
-<code class="docutils literal notranslate"><span class="pre">sys.setrecursionlimit</span></code> to reduce their visibility.</p>
+<code class="docutils literal notranslate"><span class="pre">sys.setrecursionlimit</span></code> to reduce the effect of the additional stack frames.</p>
 <p>If <code class="docutils literal notranslate"><span class="pre">sys</span></code> is reset by user code, such as with <code class="docutils literal notranslate"><span class="pre">importlib.reload(sys)</span></code>,
 IDLE’s changes are lost and input from the keyboard and output to the screen
 will not work correctly.</p>
@@ -895,7 +900,7 @@ <h3>Navigation</h3>
 <br />
     <br />
 
-    Last updated on Jul 04, 2019.
+    Last updated on Jul 23, 2019.
     <a href="https://docs.python.org/3/bugs.html">Found a bug</a>?
     <br />
 
diff --git a/Lib/idlelib/idle_test/htest.py b/Lib/idlelib/idle_test/htest.py
index 20e5e9014ee7..f2f37e161632 100644
--- a/Lib/idlelib/idle_test/htest.py
+++ b/Lib/idlelib/idle_test/htest.py
@@ -67,6 +67,7 @@ def _wrapper(parent):  # htest #
 
 import idlelib.pyshell  # Set Windows DPI awareness before Tk().
 from importlib import import_module
+import textwrap
 import tkinter as tk
 from tkinter.ttk import Scrollbar
 tk.NoDefaultRoot()
@@ -205,6 +206,19 @@ def _wrapper(parent):  # htest #
            "Check that changes were saved by opening the file elsewhere."
     }
 
+_linenumbers_drag_scrolling_spec = {
+    'file': 'sidebar',
+    'kwds': {},
+    'msg': textwrap.dedent("""\
+        Click on the line numbers and drag down below the edge of the
+        window, moving the mouse a bit and then leaving it there for a while.
+        The text and line numbers should gradually scroll down, with the
+        selection updated continuously.
+        Do the same as above, dragging to above the window. The text and line
+        numbers should gradually scroll up, with the selection updated
+        continuously."""),
+    }
+
 _multi_call_spec = {
     'file': 'multicall',
     'kwds': {},
diff --git a/Lib/idlelib/idle_test/test_codecontext.py b/Lib/idlelib/idle_test/test_codecontext.py
index c6c8e8efcd4f..3ec49e97af6f 100644
--- a/Lib/idlelib/idle_test/test_codecontext.py
+++ b/Lib/idlelib/idle_test/test_codecontext.py
@@ -4,7 +4,7 @@
 import unittest
 import unittest.mock
 from test.support import requires
-from tkinter import Tk, Frame, Text, TclError
+from tkinter import NSEW, Tk, Frame, Text, TclError
 
 from unittest import mock
 import re
@@ -62,7 +62,7 @@ def setUpClass(cls):
         text.insert('1.0', code_sample)
         # Need to pack for creation of code context text widget.
         frame.pack(side='left', fill='both', expand=1)
-        text.pack(side='top', fill='both', expand=1)
+        text.grid(row=1, column=1, sticky=NSEW)
         cls.editor = DummyEditwin(root, frame, text)
         codecontext.idleConf.userCfg = testcfg
 
@@ -77,6 +77,7 @@ def tearDownClass(cls):
 
     def setUp(self):
         self.text.yview(0)
+        self.text['font'] = 'TkFixedFont'
         self.cc = codecontext.CodeContext(self.editor)
 
         self.highlight_cfg = {"background": '#abcdef',
@@ -86,10 +87,18 @@ def mock_idleconf_GetHighlight(theme, element):
             if element == 'context':
                 return self.highlight_cfg
             return orig_idleConf_GetHighlight(theme, element)
-        patcher = unittest.mock.patch.object(
+        GetHighlight_patcher = unittest.mock.patch.object(
             codecontext.idleConf, 'GetHighlight', mock_idleconf_GetHighlight)
-        patcher.start()
-        self.addCleanup(patcher.stop)
+        GetHighlight_patcher.start()
+        self.addCleanup(GetHighlight_patcher.stop)
+
+        self.font_override = 'TkFixedFont'
+        def mock_idleconf_GetFont(root, configType, section):
+            return self.font_override
+        GetFont_patcher = unittest.mock.patch.object(
+            codecontext.idleConf, 'GetFont', mock_idleconf_GetFont)
+        GetFont_patcher.start()
+        self.addCleanup(GetFont_patcher.stop)
 
     def tearDown(self):
         if self.cc.context:
@@ -339,69 +348,59 @@ def test_timer_event(self, mock_update):
     def test_font(self):
         eq = self.assertEqual
         cc = self.cc
-        save_font = cc.text['font']
+
+        orig_font = cc.text['font']
         test_font = 'TkTextFont'
+        self.assertNotEqual(orig_font, test_font)
 
         # Ensure code context is not active.
         if cc.context is not None:
             cc.toggle_code_context_event()
 
+        self.font_override = test_font
         # Nothing breaks or changes with inactive code context.
-        cc.update_font(test_font)
+        cc.update_font()
 
-        # Activate code context, but no change to font.
-        cc.toggle_code_context_event()
-        eq(cc.context['font'], save_font)
-        # Call font update with the existing font.
-        cc.update_font(save_font)
-        eq(cc.context['font'], save_font)
+        # Activate code context, previous font change is immediately effective.
         cc.toggle_code_context_event()
-
-        # Change text widget font and activate code context.
-        cc.text['font'] = test_font
-        cc.toggle_code_context_event(test_font)
         eq(cc.context['font'], test_font)
 
-        # Just call the font update.
-        cc.update_font(save_font)
-        eq(cc.context['font'], save_font)
-        cc.text['font'] = save_font
+        # Call the font update, change is picked up.
+        self.font_override = orig_font
+        cc.update_font()
+        eq(cc.context['font'], orig_font)
 
     def test_highlight_colors(self):
         eq = self.assertEqual
         cc = self.cc
-        save_colors = dict(self.highlight_cfg)
+
+        orig_colors = dict(self.highlight_cfg)
         test_colors = {'background': '#222222', 'foreground': '#ffff00'}
 
+        def assert_colors_are_equal(colors):
+            eq(cc.context['background'], colors['background'])
+            eq(cc.context['foreground'], colors['foreground'])
+
         # Ensure code context is not active.
         if cc.context:
             cc.toggle_code_context_event()
 
+        self.highlight_cfg = test_colors
         # Nothing breaks with inactive code context.
         cc.update_highlight_colors()
 
-        # Activate code context, but no change to colors.
+        # Activate code context, previous colors change is immediately effective.
         cc.toggle_code_context_event()
-        eq(cc.context['background'], save_colors['background'])
-        eq(cc.context['foreground'], save_colors['foreground'])
+        assert_colors_are_equal(test_colors)
 
-        # Call colors update, but no change to font.
+        # Call colors update with no change to the configured colors.
         cc.update_highlight_colors()
-        eq(cc.context['background'], save_colors['background'])
-        eq(cc.context['foreground'], save_colors['foreground'])
-        cc.toggle_code_context_event()
-
-        # Change colors and activate code context.
-        self.highlight_cfg = test_colors
-        cc.toggle_code_context_event()
-        eq(cc.context['background'], test_colors['background'])
-        eq(cc.context['foreground'], test_colors['foreground'])
+        assert_colors_are_equal(test_colors)
 
-        # Change colors and call highlight colors update.
-        self.highlight_cfg = save_colors
+        # Call the colors update with code context active, change is picked up.
+        self.highlight_cfg = orig_colors
         cc.update_highlight_colors()
-        eq(cc.context['background'], save_colors['background'])
-        eq(cc.context['foreground'], save_colors['foreground'])
+        assert_colors_are_equal(orig_colors)
 
 
 class HelperFunctionText(unittest.TestCase):
diff --git a/Lib/idlelib/idle_test/test_sidebar.py b/Lib/idlelib/idle_test/test_sidebar.py
new file mode 100644
index 000000000000..8c98a0c0cbde
--- /dev/null
+++ b/Lib/idlelib/idle_test/test_sidebar.py
@@ -0,0 +1,351 @@
+"""Test sidebar, coverage 93%"""
+from itertools import chain
+import unittest
+import unittest.mock
+from test.support import requires
+import tkinter as tk
+
+from idlelib.delegator import Delegator
+from idlelib.percolator import Percolator
+import idlelib.sidebar
+
+
+class Dummy_editwin:
+    def __init__(self, text):
+        self.text = text
+        self.text_frame = self.text.master
+        self.per = Percolator(text)
+        self.undo = Delegator()
+        self.per.insertfilter(self.undo)
+
+    def setvar(self, name, value):
+        pass
+
+    def getlineno(self, index):
+        return int(float(self.text.index(index)))
+
+
+class LineNumbersTest(unittest.TestCase):
+
+    @classmethod
+    def setUpClass(cls):
+        requires('gui')
+        cls.root = tk.Tk()
+
+        cls.text_frame = tk.Frame(cls.root)
+        cls.text_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
+        cls.text_frame.rowconfigure(1, weight=1)
+        cls.text_frame.columnconfigure(1, weight=1)
+
+        cls.text = tk.Text(cls.text_frame, width=80, height=24, wrap=tk.NONE)
+        cls.text.grid(row=1, column=1, sticky=tk.NSEW)
+
+        cls.editwin = Dummy_editwin(cls.text)
+        cls.editwin.vbar = tk.Scrollbar(cls.text_frame)
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.editwin.per.close()
+        cls.root.update()
+        cls.root.destroy()
+        del cls.text, cls.text_frame, cls.editwin, cls.root
+
+    def setUp(self):
+        self.linenumber = idlelib.sidebar.LineNumbers(self.editwin)
+
+        self.highlight_cfg = {"background": '#abcdef',
+                              "foreground": '#123456'}
+        orig_idleConf_GetHighlight = idlelib.sidebar.idleConf.GetHighlight
+        def mock_idleconf_GetHighlight(theme, element):
+            if element == 'linenumber':
+                return self.highlight_cfg
+            return orig_idleConf_GetHighlight(theme, element)
+        GetHighlight_patcher = unittest.mock.patch.object(
+            idlelib.sidebar.idleConf, 'GetHighlight', mock_idleconf_GetHighlight)
+        GetHighlight_patcher.start()
+        self.addCleanup(GetHighlight_patcher.stop)
+
+        self.font_override = 'TkFixedFont'
+        def mock_idleconf_GetFont(root, configType, section):
+            return self.font_override
+        GetFont_patcher = unittest.mock.patch.object(
+            idlelib.sidebar.idleConf, 'GetFont', mock_idleconf_GetFont)
+        GetFont_patcher.start()
+        self.addCleanup(GetFont_patcher.stop)
+
+    def tearDown(self):
+        self.text.delete('1.0', 'end')
+
+    def get_selection(self):
+        return tuple(map(str, self.text.tag_ranges('sel')))
+
+    def get_line_screen_position(self, line):
+        bbox = self.linenumber.sidebar_text.bbox(f'{line}.end -1c')
+        x = bbox[0] + 2
+        y = bbox[1] + 2
+        return x, y
+
+    def assert_state_disabled(self):
+        state = self.linenumber.sidebar_text.config()['state']
+        self.assertEqual(state[-1], tk.DISABLED)
+
+    def get_sidebar_text_contents(self):
+        return self.linenumber.sidebar_text.get('1.0', tk.END)
+
+    def assert_sidebar_n_lines(self, n_lines):
+        expected = '\n'.join(chain(map(str, range(1, n_lines + 1)), ['']))
+        self.assertEqual(self.get_sidebar_text_contents(), expected)
+
+    def assert_text_equals(self, expected):
+        return self.assertEqual(self.text.get('1.0', 'end'), expected)
+
+    def test_init_empty(self):
+        self.assert_sidebar_n_lines(1)
+
+    def test_init_not_empty(self):
+        self.text.insert('insert', 'foo bar\n'*3)
+        self.assert_text_equals('foo bar\n'*3 + '\n')
+        self.assert_sidebar_n_lines(4)
+
+    def test_toggle_linenumbering(self):
+        self.assertEqual(self.linenumber.is_shown, False)
+        self.linenumber.show_sidebar()
+        self.assertEqual(self.linenumber.is_shown, True)
+        self.linenumber.hide_sidebar()
+        self.assertEqual(self.linenumber.is_shown, False)
+        self.linenumber.hide_sidebar()
+        self.assertEqual(self.linenumber.is_shown, False)
+        self.linenumber.show_sidebar()
+        self.assertEqual(self.linenumber.is_shown, True)
+        self.linenumber.show_sidebar()
+        self.assertEqual(self.linenumber.is_shown, True)
+
+    def test_insert(self):
+        self.text.insert('insert', 'foobar')
+        self.assert_text_equals('foobar\n')
+        self.assert_sidebar_n_lines(1)
+        self.assert_state_disabled()
+
+        self.text.insert('insert', '\nfoo')
+        self.assert_text_equals('foobar\nfoo\n')
+        self.assert_sidebar_n_lines(2)
+        self.assert_state_disabled()
+
+        self.text.insert('insert', 'hello\n'*2)
+        self.assert_text_equals('foobar\nfoohello\nhello\n\n')
+        self.assert_sidebar_n_lines(4)
+        self.assert_state_disabled()
+
+        self.text.insert('insert', '\nworld')
+        self.assert_text_equals('foobar\nfoohello\nhello\n\nworld\n')
+        self.assert_sidebar_n_lines(5)
+        self.assert_state_disabled()
+
+    def test_delete(self):
+        self.text.insert('insert', 'foobar')
+        self.assert_text_equals('foobar\n')
+        self.text.delete('1.1', '1.3')
+        self.assert_text_equals('fbar\n')
+        self.assert_sidebar_n_lines(1)
+        self.assert_state_disabled()
+
+        self.text.insert('insert', 'foo\n'*2)
+        self.assert_text_equals('fbarfoo\nfoo\n\n')
+        self.assert_sidebar_n_lines(3)
+        self.assert_state_disabled()
+
+        # Note: deleting up to "2.end" doesn't delete the final newline.
+        self.text.delete('2.0', '2.end')
+        self.assert_text_equals('fbarfoo\n\n\n')
+        self.assert_sidebar_n_lines(3)
+        self.assert_state_disabled()
+
+        self.text.delete('1.3', 'end')
+        self.assert_text_equals('fba\n')
+        self.assert_sidebar_n_lines(1)
+        self.assert_state_disabled()
+
+        # Note: Text widgets always keep a single '\n' character at the end.
+        self.text.delete('1.0', 'end')
+        self.assert_text_equals('\n')
+        self.assert_sidebar_n_lines(1)
+        self.assert_state_disabled()
+
+    def test_sidebar_text_width(self):
+        """
+        Test that linenumber text widget is always at the minimum
+        width
+        """
+        def get_width():
+            return self.linenumber.sidebar_text.config()['width'][-1]
+
+        self.assert_sidebar_n_lines(1)
+        self.assertEqual(get_width(), 1)
+
+        self.text.insert('insert', 'foo')
+        self.assert_sidebar_n_lines(1)
+        self.assertEqual(get_width(), 1)
+
+        self.text.insert('insert', 'foo\n'*8)
+        self.assert_sidebar_n_lines(9)
+        self.assertEqual(get_width(), 1)
+
+        self.text.insert('insert', 'foo\n')
+        self.assert_sidebar_n_lines(10)
+        self.assertEqual(get_width(), 2)
+
+        self.text.insert('insert', 'foo\n')
+        self.assert_sidebar_n_lines(11)
+        self.assertEqual(get_width(), 2)
+
+        self.text.delete('insert -1l linestart', 'insert linestart')
+        self.assert_sidebar_n_lines(10)
+        self.assertEqual(get_width(), 2)
+
+        self.text.delete('insert -1l linestart', 'insert linestart')
+        self.assert_sidebar_n_lines(9)
+        self.assertEqual(get_width(), 1)
+
+        self.text.insert('insert', 'foo\n'*90)
+        self.assert_sidebar_n_lines(99)
+        self.assertEqual(get_width(), 2)
+
+        self.text.insert('insert', 'foo\n')
+        self.assert_sidebar_n_lines(100)
+        self.assertEqual(get_width(), 3)
+
+        self.text.insert('insert', 'foo\n')
+        self.assert_sidebar_n_lines(101)
+        self.assertEqual(get_width(), 3)
+
+        self.text.delete('insert -1l linestart', 'insert linestart')
+        self.assert_sidebar_n_lines(100)
+        self.assertEqual(get_width(), 3)
+
+        self.text.delete('insert -1l linestart', 'insert linestart')
+        self.assert_sidebar_n_lines(99)
+        self.assertEqual(get_width(), 2)
+
+        self.text.delete('50.0 -1c', 'end -1c')
+        self.assert_sidebar_n_lines(49)
+        self.assertEqual(get_width(), 2)
+
+        self.text.delete('5.0 -1c', 'end -1c')
+        self.assert_sidebar_n_lines(4)
+        self.assertEqual(get_width(), 1)
+
+        # Note: Text widgets always keep a single '\n' character at the end.
+        self.text.delete('1.0', 'end -1c')
+        self.assert_sidebar_n_lines(1)
+        self.assertEqual(get_width(), 1)
+
+    def test_click_selection(self):
+        self.linenumber.show_sidebar()
+        self.text.insert('1.0', 'one\ntwo\nthree\nfour\n')
+        self.root.update()
+
+        # Click on the second line.
+        x, y = self.get_line_screen_position(2)
+        self.linenumber.sidebar_text.event_generate('<Button-1>', x=x, y=y)
+        self.linenumber.sidebar_text.update()
+        self.root.update()
+
+        self.assertEqual(self.get_selection(), ('2.0', '3.0'))
+
+    def test_drag_selection(self):
+        self.linenumber.show_sidebar()
+        self.text.insert('1.0', 'one\ntwo\nthree\nfour\n')
+        self.root.update()
+
+        # Drag from the first line to the third line.
+        start_x, start_y = self.get_line_screen_position(1)
+        end_x, end_y = self.get_line_screen_position(3)
+        self.linenumber.sidebar_text.event_generate('<Button-1>',
+                                                    x=start_x, y=start_y)
+        self.linenumber.sidebar_text.event_generate('<B1-Motion>',
+                                                    x=start_x, y=start_y)
+        self.linenumber.sidebar_text.event_generate('<B1-Motion>',
+                                                    x=end_x, y=end_y)
+        self.linenumber.sidebar_text.event_generate('<ButtonRelease-1>',
+                                                    x=end_x, y=end_y)
+        self.root.update()
+
+        self.assertEqual(self.get_selection(), ('1.0', '4.0'))
+
+    def test_scroll(self):
+        self.linenumber.show_sidebar()
+        self.text.insert('1.0', 'line\n' * 100)
+        self.root.update()
+
+        # Scroll down 10 lines.
+        self.text.yview_scroll(10, 'unit')
+        self.root.update()
+        self.assertEqual(self.text.index('@0,0'), '11.0')
+        self.assertEqual(self.linenumber.sidebar_text.index('@0,0'), '11.0')
+
+        # Generate a mouse-wheel event and make sure it scrolled up or down.
+        # The meaning of the "delta" is OS-dependant, so this just checks for
+        # any change.
+        self.linenumber.sidebar_text.event_generate('<MouseWheel>',
+                                                    x=0, y=0,
+                                                    delta=10)
+        self.root.update()
+        self.assertNotEqual(self.text.index('@0,0'), '11.0')
+        self.assertNotEqual(self.linenumber.sidebar_text.index('@0,0'), '11.0')
+
+    def test_font(self):
+        ln = self.linenumber
+
+        orig_font = ln.sidebar_text['font']
+        test_font = 'TkTextFont'
+        self.assertNotEqual(orig_font, test_font)
+
+        # Ensure line numbers aren't shown.
+        ln.hide_sidebar()
+
+        self.font_override = test_font
+        # Nothing breaks when line numbers aren't shown.
+        ln.update_font()
+
+        # Activate line numbers, previous font change is immediately effective.
+        ln.show_sidebar()
+        self.assertEqual(ln.sidebar_text['font'], test_font)
+
+        # Call the font update with line numbers shown, change is picked up.
+        self.font_override = orig_font
+        ln.update_font()
+        self.assertEqual(ln.sidebar_text['font'], orig_font)
+
+    def test_highlight_colors(self):
+        ln = self.linenumber
+
+        orig_colors = dict(self.highlight_cfg)
+        test_colors = {'background': '#222222', 'foreground': '#ffff00'}
+
+        def assert_colors_are_equal(colors):
+            self.assertEqual(ln.sidebar_text['background'], colors['background'])
+            self.assertEqual(ln.sidebar_text['foreground'], colors['foreground'])
+
+        # Ensure line numbers aren't shown.
+        ln.hide_sidebar()
+
+        self.highlight_cfg = test_colors
+        # Nothing breaks with inactive code context.
+        ln.update_colors()
+
+        # Show line numbers, previous colors change is immediately effective.
+        ln.show_sidebar()
+        assert_colors_are_equal(test_colors)
+
+        # Call colors update with no change to the configured colors.
+        ln.update_colors()
+        assert_colors_are_equal(test_colors)
+
+        # Call the colors update with line numbers shown, change is picked up.
+        self.highlight_cfg = orig_colors
+        ln.update_colors()
+        assert_colors_are_equal(orig_colors)
+
+
+if __name__ == '__main__':
+    unittest.main(verbosity=2)
diff --git a/Lib/idlelib/mainmenu.py b/Lib/idlelib/mainmenu.py
index b0c85cf505c7..fc51fb1b5d34 100644
--- a/Lib/idlelib/mainmenu.py
+++ b/Lib/idlelib/mainmenu.py
@@ -100,7 +100,8 @@
    ('Configure _IDLE', '<<open-config-dialog>>'),
    None,
    ('Show _Code Context', '<<toggle-code-context>>'),
-   ('Zoom Height', '<<zoom-height>>'),
+   ('Show _Line Numbers', '<<toggle-line-numbers>>'),
+   ('_Zoom Height', '<<zoom-height>>'),
    ]),
 
  ('window', [
diff --git a/Lib/idlelib/outwin.py b/Lib/idlelib/outwin.py
index 38c59bdf9b4e..90272b6feb4a 100644
--- a/Lib/idlelib/outwin.py
+++ b/Lib/idlelib/outwin.py
@@ -74,13 +74,11 @@ class OutputWindow(EditorWindow):
         ("Go to file/line", "<<goto-file-line>>", None),
     ]
 
-    allow_codecontext = False
+    allow_code_context = False
 
     def __init__(self, *args):
         EditorWindow.__init__(self, *args)
         self.text.bind("<<goto-file-line>>", self.goto_file_line)
-        self.text.unbind("<<toggle-code-context>>")
-        self.update_menu_state('options', '*Code Context', 'disabled')
 
     # Customize EditorWindow
     def ispythonsource(self, filename):
diff --git a/Lib/idlelib/pyshell.py b/Lib/idlelib/pyshell.py
index 7ad5a76c3bd5..87401f33f55f 100755
--- a/Lib/idlelib/pyshell.py
+++ b/Lib/idlelib/pyshell.py
@@ -861,6 +861,8 @@ class PyShell(OutputWindow):
         ("Squeeze", "<<squeeze-current-text>>"),
     ]
 
+    allow_line_numbers = False
+
     # New classes
     from idlelib.history import History
 
diff --git a/Lib/idlelib/sidebar.py b/Lib/idlelib/sidebar.py
new file mode 100644
index 000000000000..8f9bf80b5260
--- /dev/null
+++ b/Lib/idlelib/sidebar.py
@@ -0,0 +1,324 @@
+"""Line numbering implementation for IDLE as an extension.
+Includes BaseSideBar which can be extended for other sidebar based extensions
+"""
+import functools
+import itertools
+
+import tkinter as tk
+from idlelib.config import idleConf
+from idlelib.delegator import Delegator
+
+
+def get_end_linenumber(text):
+    """Utility to get the last line's number in a Tk text widget."""
+    return int(float(text.index('end-1c')))
+
+
+def get_widget_padding(widget):
+    """Get the total padding of a Tk widget, including its border."""
+    # TODO: use also in codecontext.py
+    manager = widget.winfo_manager()
+    if manager == 'pack':
+        info = widget.pack_info()
+    elif manager == 'grid':
+        info = widget.grid_info()
+    else:
+        raise ValueError(f"Unsupported geometry manager: {manager}")
+
+    # All values are passed through getint(), since some
+    # values may be pixel objects, which can't simply be added to ints.
+    padx = sum(map(widget.tk.getint, [
+        info['padx'],
+        widget.cget('padx'),
+        widget.cget('border'),
+    ]))
+    pady = sum(map(widget.tk.getint, [
+        info['pady'],
+        widget.cget('pady'),
+        widget.cget('border'),
+    ]))
+    return padx, pady
+
+
+class BaseSideBar:
+    """
+    The base class for extensions which require a sidebar.
+    """
+    def __init__(self, editwin):
+        self.editwin = editwin
+        self.parent = editwin.text_frame
+        self.text = editwin.text
+
+        _padx, pady = get_widget_padding(self.text)
+        self.sidebar_text = tk.Text(self.parent, width=1, wrap=tk.NONE,
+                                    padx=0, pady=pady,
+                                    borderwidth=0, highlightthickness=0)
+        self.sidebar_text.config(state=tk.DISABLED)
+        self.text['yscrollcommand'] = self.redirect_yscroll_event
+        self.update_font()
+        self.update_colors()
+
+        self.is_shown = False
+
+    def update_font(self):
+        """Update the sidebar text font, usually after config changes."""
+        font = idleConf.GetFont(self.text, 'main', 'EditorWindow')
+        self._update_font(font)
+
+    def _update_font(self, font):
+        self.sidebar_text['font'] = font
+
+    def update_colors(self):
+        """Update the sidebar text colors, usually after config changes."""
+        colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'normal')
+        self._update_colors(foreground=colors['foreground'],
+                            background=colors['background'])
+
+    def _update_colors(self, foreground, background):
+        self.sidebar_text.config(
+            fg=foreground, bg=background,
+            selectforeground=foreground, selectbackground=background,
+            inactiveselectbackground=background,
+        )
+
+    def show_sidebar(self):
+        if not self.is_shown:
+            self.sidebar_text.grid(row=1, column=0, sticky=tk.NSEW)
+            self.is_shown = True
+
+    def hide_sidebar(self):
+        if self.is_shown:
+            self.sidebar_text.grid_forget()
+            self.is_shown = False
+
+    def redirect_yscroll_event(self, *args, **kwargs):
+        """Redirect vertical scrolling to the main editor text widget.
+
+        The scroll bar is also updated.
+        """
+        self.editwin.vbar.set(*args)
+        self.sidebar_text.yview_moveto(args[0])
+        return 'break'
+
+    def redirect_focusin_event(self, event):
+        """Redirect focus-in events to the main editor text widget."""
+        self.text.focus_set()
+        return 'break'
+
+    def redirect_mousebutton_event(self, event, event_name):
+        """Redirect mouse button events to the main editor text widget."""
+        self.text.focus_set()
+        self.text.event_generate(event_name, x=0, y=event.y)
+        return 'break'
+
+    def redirect_mousewheel_event(self, event):
+        """Redirect mouse wheel events to the editwin text widget."""
+        self.text.event_generate('<MouseWheel>',
+                                 x=0, y=event.y, delta=event.delta)
+        return 'break'
+
+
+class EndLineDelegator(Delegator):
+    """Generate callbacks with the current end line number after
+       insert or delete operations"""
+    def __init__(self, changed_callback):
+        """
+        changed_callback - Callable, will be called after insert
+                           or delete operations with the current
+                           end line number.
+        """
+        Delegator.__init__(self)
+        self.changed_callback = changed_callback
+
+    def insert(self, index, chars, tags=None):
+        self.delegate.insert(index, chars, tags)
+        self.changed_callback(get_end_linenumber(self.delegate))
+
+    def delete(self, index1, index2=None):
+        self.delegate.delete(index1, index2)
+        self.changed_callback(get_end_linenumber(self.delegate))
+
+
+class LineNumbers(BaseSideBar):
+    """Line numbers support for editor windows."""
+    def __init__(self, editwin):
+        BaseSideBar.__init__(self, editwin)
+        self.prev_end = 1
+        self._sidebar_width_type = type(self.sidebar_text['width'])
+        self.sidebar_text.config(state=tk.NORMAL)
+        self.sidebar_text.insert('insert', '1', 'linenumber')
+        self.sidebar_text.config(state=tk.DISABLED)
+        self.sidebar_text.config(takefocus=False, exportselection=False)
+        self.sidebar_text.tag_config('linenumber', justify=tk.RIGHT)
+
+        self.bind_events()
+
+        end = get_end_linenumber(self.text)
+        self.update_sidebar_text(end)
+
+        end_line_delegator = EndLineDelegator(self.update_sidebar_text)
+        # Insert the delegator after the undo delegator, so that line numbers
+        # are properly updated after undo and redo actions.
+        end_line_delegator.setdelegate(self.editwin.undo.delegate)
+        self.editwin.undo.setdelegate(end_line_delegator)
+        # Reset the delegator caches of the delegators "above" the
+        # end line delegator we just inserted.
+        delegator = self.editwin.per.top
+        while delegator is not end_line_delegator:
+            delegator.resetcache()
+            delegator = delegator.delegate
+
+        self.is_shown = False
+
+    def bind_events(self):
+        # Ensure focus is always redirected to the main editor text widget.
+        self.sidebar_text.bind('<FocusIn>', self.redirect_focusin_event)
+
+        # Redirect mouse scrolling to the main editor text widget.
+        #
+        # Note that without this, scrolling with the mouse only scrolls
+        # the line numbers.
+        self.sidebar_text.bind('<MouseWheel>', self.redirect_mousewheel_event)
+
+        # Redirect mouse button events to the main editor text widget,
+        # except for the left mouse button (1).
+        #
+        # Note: X-11 sends Button-4 and Button-5 events for the scroll wheel.
+        def bind_mouse_event(event_name, target_event_name):
+            handler = functools.partial(self.redirect_mousebutton_event,
+                                        event_name=target_event_name)
+            self.sidebar_text.bind(event_name, handler)
+
+        for button in [2, 3, 4, 5]:
+            for event_name in (f'<Button-{button}>',
+                               f'<ButtonRelease-{button}>',
+                               f'<B{button}-Motion>',
+                               ):
+                bind_mouse_event(event_name, target_event_name=event_name)
+
+            # Convert double- and triple-click events to normal click events,
+            # since event_generate() doesn't allow generating such events.
+            for event_name in (f'<Double-Button-{button}>',
+                               f'<Triple-Button-{button}>',
+                               ):
+                bind_mouse_event(event_name,
+                                 target_event_name=f'<Button-{button}>')
+
+        start_line = None
+        def b1_mousedown_handler(event):
+            # select the entire line
+            lineno = self.editwin.getlineno(f"@0,{event.y}")
+            self.text.tag_remove("sel", "1.0", "end")
+            self.text.tag_add("sel", f"{lineno}.0", f"{lineno+1}.0")
+            self.text.mark_set("insert", f"{lineno+1}.0")
+
+            # remember this line in case this is the beginning of dragging
+            nonlocal start_line
+            start_line = lineno
+        self.sidebar_text.bind('<Button-1>', b1_mousedown_handler)
+
+        # These are set by b1_motion_handler() and read by selection_handler();
+        # see below.  last_y is passed this way since the mouse Y-coordinate
+        # is not available on selection event objects.  last_yview is passed
+        # this way to recognize scrolling while the mouse isn't moving.
+        last_y = last_yview = None
+
+        def drag_update_selection_and_insert_mark(y_coord):
+            """Helper function for drag and selection event handlers."""
+            lineno = self.editwin.getlineno(f"@0,{y_coord}")
+            a, b = sorted([start_line, lineno])
+            self.text.tag_remove("sel", "1.0", "end")
+            self.text.tag_add("sel", f"{a}.0", f"{b+1}.0")
+            self.text.mark_set("insert",
+                               f"{lineno if lineno == a else lineno + 1}.0")
+
+        # Special handling of dragging with mouse button 1.  In "normal" text
+        # widgets this selects text, but the line numbers text widget has
+        # selection disabled.  Still, dragging triggers some selection-related
+        # functionality under the hood.  Specifically, dragging to above or
+        # below the text widget triggers scrolling, in a way that bypasses the
+        # other scrolling synchronization mechanisms.i
+        def b1_drag_handler(event, *args):
+            nonlocal last_y
+            nonlocal last_yview
+            last_y = event.y
+            last_yview = self.sidebar_text.yview()
+            if not 0 <= last_y <= self.sidebar_text.winfo_height():
+                self.text.yview_moveto(last_yview[0])
+            drag_update_selection_and_insert_mark(event.y)
+        self.sidebar_text.bind('<B1-Motion>', b1_drag_handler)
+
+        # With mouse-drag scrolling fixed by the above, there is still an edge-
+        # case we need to handle: When drag-scrolling, scrolling can continue
+        # while the mouse isn't moving, leading to the above fix not scrolling
+        # properly.
+        def selection_handler(event):
+            yview = self.sidebar_text.yview()
+            if yview != last_yview:
+                self.text.yview_moveto(yview[0])
+                drag_update_selection_and_insert_mark(last_y)
+        self.sidebar_text.bind('<<Selection>>', selection_handler)
+
+    def update_colors(self):
+        """Update the sidebar text colors, usually after config changes."""
+        colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber')
+        self._update_colors(foreground=colors['foreground'],
+                            background=colors['background'])
+
+    def update_sidebar_text(self, end):
+        """
+        Perform the following action:
+        Each line sidebar_text contains the linenumber for that line
+        Synchronize with editwin.text so that both sidebar_text and
+        editwin.text contain the same number of lines"""
+        if end == self.prev_end:
+            return
+
+        width_difference = len(str(end)) - len(str(self.prev_end))
+        if width_difference:
+            cur_width = int(float(self.sidebar_text['width']))
+            new_width = cur_width + width_difference
+            self.sidebar_text['width'] = self._sidebar_width_type(new_width)
+
+        self.sidebar_text.config(state=tk.NORMAL)
+        if end > self.prev_end:
+            new_text = '\n'.join(itertools.chain(
+                [''],
+                map(str, range(self.prev_end + 1, end + 1)),
+            ))
+            self.sidebar_text.insert(f'end -1c', new_text, 'linenumber')
+        else:
+            self.sidebar_text.delete(f'{end+1}.0 -1c', 'end -1c')
+        self.sidebar_text.config(state=tk.DISABLED)
+
+        self.prev_end = end
+
+
+def _linenumbers_drag_scrolling(parent):  # htest #
+    from idlelib.idle_test.test_sidebar import Dummy_editwin
+
+    toplevel = tk.Toplevel(parent)
+    text_frame = tk.Frame(toplevel)
+    text_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
+    text_frame.rowconfigure(1, weight=1)
+    text_frame.columnconfigure(1, weight=1)
+
+    font = idleConf.GetFont(toplevel, 'main', 'EditorWindow')
+    text = tk.Text(text_frame, width=80, height=24, wrap=tk.NONE, font=font)
+    text.grid(row=1, column=1, sticky=tk.NSEW)
+
+    editwin = Dummy_editwin(text)
+    editwin.vbar = tk.Scrollbar(text_frame)
+
+    linenumbers = LineNumbers(editwin)
+    linenumbers.show_sidebar()
+
+    text.insert('1.0', '\n'.join('a'*i for i in range(1, 101)))
+
+
+if __name__ == '__main__':
+    from unittest import main
+    main('idlelib.idle_test.test_sidebar', verbosity=2, exit=False)
+
+    from idlelib.idle_test.htest import run
+    run(_linenumbers_drag_scrolling)
diff --git a/Misc/NEWS.d/next/IDLE/2019-06-13-01-07-20.bpo-17535.K8i2St.rst b/Misc/NEWS.d/next/IDLE/2019-06-13-01-07-20.bpo-17535.K8i2St.rst
new file mode 100644
index 000000000000..201a413b42a3
--- /dev/null
+++ b/Misc/NEWS.d/next/IDLE/2019-06-13-01-07-20.bpo-17535.K8i2St.rst
@@ -0,0 +1,4 @@
+Add optional line numbers for IDLE editor windows.  Windows
+open without line numbers unless set otherwise in the General
+tab of the configuration dialog. 
+



More information about the Python-checkins mailing list