line numbering implementation for idle as an extension includes basesidebar which can be extended for other sidebar based extensions return the line number of an index in a tk text widget textindex text indexindex return intfloattextindex if textindex else none def getendlinenumbertext display height in lines of a logical line in a tk text widget return text countfindex linestart findex lineend displaylines def getwidgetpaddingwidget todo use also in codecontext py all values are passed through getint since some values may be pixel objects which can t simply be added to ints a base class for sidebars using text def initself editwin self editwin editwin self parent editwin textframe self text editwin text self isshown false self mainwidget self initwidgets self bindevents self updatefont self updatecolors def initwidgetsself update the sidebar text font usually after config changes raise notimplementederror def updatecolorsself layout the widget always using grid layout raise notimplementederror def showsidebarself if not self isshown self grid self isshown true def hidesidebarself if self isshown self mainwidget gridforget self isshown false def yscrolleventself args kwargs redirect vertical scrolling to the main editor text widget the scroll bar is also updated redirect focusin events to the main editor text widget self text focusset return break def redirectmousebuttoneventself event eventname redirect mouse wheel events to the editwin text widget self text eventgenerate mousewheel x0 yevent y deltaevent delta return break def bindeventsself self text yscrollcommand self redirectyscrollevent ensure focus is always redirected to the main editor text widget self mainwidget bind focusin self redirectfocusinevent redirect mouse scrolling to the main editor text widget note that without this scrolling with the mouse only scrolls the line numbers self mainwidget bind mousewheel self redirectmousewheelevent redirect mouse button events to the main editor text widget except for the left mouse button 1 note x11 sends button4 and button5 events for the scroll wheel def bindmouseeventeventname targeteventname handler functools partialself redirectmousebuttonevent eventnametargeteventname self mainwidget bindeventname handler for button in 2 3 4 5 for eventname in f buttonbutton f buttonreleasebutton f bbuttonmotion bindmouseeventeventname targeteventnameeventname convert double and tripleclick events to normal click events since eventgenerate doesn t allow generating such events for eventname in f doublebuttonbutton f triplebuttonbutton bindmouseeventeventname targeteventnamef buttonbutton startline is set upon button1 to allow selecting a range of rows by dragging it is cleared upon buttonrelease1 startline none lasty is initially set upon b1leave and is continuously updated upon b1motion until b1enter or the mouse button is released it is used in textautoscroll which is called repeatedly and does have a mouse event available lasty none autoscrollingafterid is set whenever textautoscroll is scheduled via after it is used to stop the autoscrolling upon b1enter as well as to avoid scheduling the function several times in parallel autoscrollingafterid none def dragupdateselectionandinsertmarkycoord on mouse up we re no longer dragging set the shared persistent variables to none to represent this mimic text autoscrolling when dragging outside of it see https github comtcltktkblob064ff9941b4b80b85916a8afe86a6c21fd388b54librarytext tcll670 nonlocal autoscrollingafterid y lasty if y is none self mainwidget aftercancelautoscrollingafterid autoscrollingafterid none return elif y 0 self text yviewscroll1 y pixels dragupdateselectionandinsertmarky elif y self mainwidget winfoheight self text yviewscroll1 y self mainwidget winfoheight pixels dragupdateselectionandinsertmarky autoscrollingafterid self mainwidget after50 textautoscroll def b1leavehandlerevent schedule the initial call to textautoscroll if not already scheduled nonlocal autoscrollingafterid if autoscrollingafterid is none nonlocal lasty lasty event y autoscrollingafterid self mainwidget after0 textautoscroll self mainwidget bind b1leave b1leavehandler def b1enterhandlerevent cancel the scheduling of textautoscroll if it exists nonlocal autoscrollingafterid if autoscrollingafterid is not none self mainwidget aftercancelautoscrollingafterid autoscrollingafterid none self mainwidget bind b1enter b1enterhandler class endlinedelegatordelegator def initself changedcallback delegator initself self changedcallback changedcallback def insertself index chars tagsnone self delegate insertindex chars tags self changedcallbackgetendlinenumberself delegate def deleteself index1 index2none self delegate deleteindex1 index2 self changedcallbackgetendlinenumberself delegate class linenumbersbasesidebar insert the delegator after the undo delegator so that line numbers are properly updated after undo and redo actions update the sidebar text colors usually after config changes colors idleconf gethighlightidleconf currenttheme linenumber foreground colors foreground background colors background self sidebartext config fgforeground bgbackground selectforegroundforeground selectbackgroundbackground inactiveselectbackgroundbackground def updatesidebartextself end callback callable will be called when an insert delete or replace action on the text widget may require updating the shell sidebar sidebar for the pyshell window for prompts etc def initself editwin self canvas none self lineprompts super initeditwin changedelegator wrappedlineheightchangedelegatorself changecallback insert the textchangedelegator after the last delegator so that the sidebar reflects final changes to the text widget contents d self editwin per top if d delegate is not self text while d delegate is not self editwin per bottom d d delegate self editwin per insertfilterafterchangedelegator afterd self isshown true def initwidgetsself self canvas tk canvasself parent width30 borderwidth0 highlightthickness0 takefocusfalse self updatesidebar self grid return self canvas def bindeventsself super bindevents self mainwidget bind aquatk defines 2 as the right button not 3 button2 if macosx isaquatk else button3 self contextmenuevent def contextmenueventself event rmenu tk menuself mainwidget tearoff0 hasselection boolself text tagnextrange sel 1 0 def mkcmdeventname return lambda self text eventgenerateeventname rmenu addcommandlabel copy commandmkcmd copy state normal if hasselection else disabled rmenu addcommandlabel copy with prompts commandmkcmd copywithprompts state normal if hasselection else disabled rmenu tkpopupevent xroot event yroot return break def gridself self canvas gridrow1 column0 stickytk nsew padx2 pady0 def changecallbackself if self isshown self updatesidebar def updatesidebarself text self text texttagnames text tagnames canvas self canvas lineprompts self lineprompts canvas deletetk all index text index0 0 if index split 11 0 index text indexf index1line linestart while lineinfo text dlineinfoindex is not none y lineinfo1 prevnewlinetagnames texttagnamesfindex linestart 1c prompt if console in prevnewlinetagnames else if stdin in prevnewlinetagnames else none if prompt canvas createtext2 y anchortk nw textprompt fontself font fillself colors0 lineno getlinenotext index linepromptslineno prompt index text indexf index1line def yscrolleventself args kwargs self changecallback return break def updatefontself update the sidebar text colors usually after config changes linenumberscolors idleconf gethighlightidleconf currenttheme linenumber promptcolors idleconf gethighlightidleconf currenttheme console foreground promptcolors foreground background linenumberscolors background self colors foreground background self canvas configurebackgroundbackground self changecallback def linenumbersdragscrollingparent htest from idlelib idletest testsidebar import dummyeditwin top tk toplevelparent textframe tk frametop textframe packsidetk left filltk both expandtrue textframe rowconfigure1 weight1 textframe columnconfigure1 weight1 font idleconf getfonttop main editorwindow text tk texttextframe width80 height24 wraptk none fontfont text gridrow1 column1 stickytk nsew editwin dummyeditwintext editwin vbar tk scrollbartextframe linenumbers linenumberseditwin linenumbers showsidebar text insert 1 0 n join a i for i in range1 101 if name main from unittest import main main idlelib idletest testsidebar verbosity2 exitfalse from idlelib idletest htest import run runlinenumbersdragscrolling return the line number of an index in a tk text widget return the number of the last line in a tk text widget display height in lines of a logical line in a tk text widget get the total padding of a tk widget including its border todo use also in codecontext py all values are passed through getint since some values may be pixel objects which can t simply be added to ints a base class for sidebars using text initialize the sidebar s widgets returning the main widget update the sidebar text font usually after config changes update the sidebar text colors usually after config changes layout the widget always using grid layout hook for vertical scrolling for sub classes to override redirect vertical scrolling to the main editor text widget the scroll bar is also updated redirect focus in events to the main editor text widget redirect mouse button events to the main editor text widget redirect mouse wheel events to the editwin text widget ensure focus is always redirected to the main editor text widget redirect mouse scrolling to the main editor text widget note that without this scrolling with the mouse only scrolls the line numbers 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 convert double and triple click events to normal click events since event_generate doesn t allow generating such events start_line is set upon button 1 to allow selecting a range of rows by dragging it is cleared upon buttonrelease 1 last_y is initially set upon b1 leave and is continuously updated upon b1 motion until b1 enter or the mouse button is released it is used in text_auto_scroll which is called repeatedly and does have a mouse event available auto_scrolling_after_id is set whenever text_auto_scroll is scheduled via after it is used to stop the auto scrolling upon b1 enter as well as to avoid scheduling the function several times in parallel helper function for drag and selection event handlers on mouse up we re no longer dragging set the shared persistent variables to none to represent this i e if not currently dragging mimic text auto scrolling when dragging outside of it see https github com tcltk tk blob 064ff9941b4b80b85916a8afe86a6c21fd388b54 library text tcl l670 schedule the initial call to text_auto_scroll if not already scheduled cancel the scheduling of text_auto_scroll if it exists generate callbacks with the current end line number the provided callback is called after every insert and delete line numbers support for editor windows insert the delegator after the undo delegator so that line numbers are properly updated after undo and redo actions update the sidebar text colors usually after config changes 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 callback callable will be called when an insert delete or replace action on the text widget may require updating the shell sidebar no need to update the sidebar no need to update the sidebar sidebar for the pyshell window for prompts etc insert the textchangedelegator after the last delegator so that the sidebar reflects final changes to the text widget contents aquatk defines 2 as the right button not 3 redirect vertical scrolling to the main editor text widget the scroll bar is also updated update the sidebar text font usually after config changes update the sidebar text colors usually after config changes htest
import contextlib
import functools
import itertools

import tkinter as tk
from tkinter.font import Font
from idlelib.config import idleConf
from idlelib.delegator import Delegator
from idlelib import macosx


def get_lineno(text, index):
    
    text_index = text.index(index)
    return int(float(text_index)) if text_index else None


def get_end_linenumber(text):
    
    return get_lineno(text, 'end-1c')


def get_displaylines(text, index):
    
    return text.count(f"{index} linestart",
                      f"{index} lineend",
                      "displaylines")

def get_widget_padding(widget):
    
    
    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}")

    
    
    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


@contextlib.contextmanager
def temp_enable_text_widget(text):
    text.configure(state=tk.NORMAL)
    try:
        yield
    finally:
        text.configure(state=tk.DISABLED)


class BaseSideBar:
    
    def __init__(self, editwin):
        self.editwin = editwin
        self.parent = editwin.text_frame
        self.text = editwin.text

        self.is_shown = False

        self.main_widget = self.init_widgets()

        self.bind_events()

        self.update_font()
        self.update_colors()

    def init_widgets(self):
        
        raise NotImplementedError

    def update_font(self):
        
        raise NotImplementedError

    def update_colors(self):
        
        raise NotImplementedError

    def grid(self):
        
        raise NotImplementedError

    def show_sidebar(self):
        if not self.is_shown:
            self.grid()
            self.is_shown = True

    def hide_sidebar(self):
        if self.is_shown:
            self.main_widget.grid_forget()
            self.is_shown = False

    def yscroll_event(self, *args, **kwargs):
        
        raise NotImplementedError

    def redirect_yscroll_event(self, *args, **kwargs):
        
        self.editwin.vbar.set(*args)
        return self.yscroll_event(*args, **kwargs)

    def redirect_focusin_event(self, event):
        
        self.text.focus_set()
        return 'break'

    def redirect_mousebutton_event(self, event, event_name):
        
        self.text.focus_set()
        self.text.event_generate(event_name, x=0, y=event.y)
        return 'break'

    def redirect_mousewheel_event(self, event):
        
        self.text.event_generate('<MouseWheel>',
                                 x=0, y=event.y, delta=event.delta)
        return 'break'

    def bind_events(self):
        self.text['yscrollcommand'] = self.redirect_yscroll_event
        self.main_widget.bind('<FocusIn>', self.redirect_focusin_event)
        self.main_widget.bind('<MouseWheel>', self.redirect_mousewheel_event)

        
        def bind_mouse_event(event_name, target_event_name):
            handler = functools.partial(self.redirect_mousebutton_event,
                                        event_name=target_event_name)
            self.main_widget.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)

            
            
            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
        
        last_y = None
        auto_scrolling_after_id = None

        def drag_update_selection_and_insert_mark(y_coord):
            
            lineno = get_lineno(self.text, 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")

        def b1_mousedown_handler(event):
            nonlocal start_line
            nonlocal last_y
            start_line = int(float(self.text.index(f"@0,{event.y}")))
            last_y = event.y

            drag_update_selection_and_insert_mark(event.y)
        self.main_widget.bind('<Button-1>', b1_mousedown_handler)

        def b1_mouseup_handler(event):
            
            nonlocal start_line
            nonlocal last_y
            start_line = None
            last_y = None
            self.text.event_generate('<ButtonRelease-1>', x=0, y=event.y)
        self.main_widget.bind('<ButtonRelease-1>', b1_mouseup_handler)

        def b1_drag_handler(event):
            nonlocal last_y
            if last_y is None:  
                return
            last_y = event.y
            drag_update_selection_and_insert_mark(event.y)
        self.main_widget.bind('<B1-Motion>', b1_drag_handler)

        def text_auto_scroll():
            
            
            nonlocal auto_scrolling_after_id
            y = last_y
            if y is None:
                self.main_widget.after_cancel(auto_scrolling_after_id)
                auto_scrolling_after_id = None
                return
            elif y < 0:
                self.text.yview_scroll(-1 + y, 'pixels')
                drag_update_selection_and_insert_mark(y)
            elif y > self.main_widget.winfo_height():
                self.text.yview_scroll(1 + y - self.main_widget.winfo_height(),
                                       'pixels')
                drag_update_selection_and_insert_mark(y)
            auto_scrolling_after_id = \
                self.main_widget.after(50, text_auto_scroll)

        def b1_leave_handler(event):
            
            
            nonlocal auto_scrolling_after_id
            if auto_scrolling_after_id is None:
                nonlocal last_y
                last_y = event.y
                auto_scrolling_after_id = \
                    self.main_widget.after(0, text_auto_scroll)
        self.main_widget.bind('<B1-Leave>', b1_leave_handler)

        def b1_enter_handler(event):
            
            nonlocal auto_scrolling_after_id
            if auto_scrolling_after_id is not None:
                self.main_widget.after_cancel(auto_scrolling_after_id)
                auto_scrolling_after_id = None
        self.main_widget.bind('<B1-Enter>', b1_enter_handler)


class EndLineDelegator(Delegator):
    
    def __init__(self, changed_callback):
        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):
    
    def __init__(self, editwin):
        super().__init__(editwin)

        end_line_delegator = EndLineDelegator(self.update_sidebar_text)
        
        
        self.editwin.per.insertfilterafter(end_line_delegator,
                                           after=self.editwin.undo)

    def init_widgets(self):
        _padx, pady = get_widget_padding(self.text)
        self.sidebar_text = tk.Text(self.parent, width=1, wrap=tk.NONE,
                                    padx=2, pady=pady,
                                    borderwidth=0, highlightthickness=0)
        self.sidebar_text.config(state=tk.DISABLED)

        self.prev_end = 1
        self._sidebar_width_type = type(self.sidebar_text['width'])
        with temp_enable_text_widget(self.sidebar_text):
            self.sidebar_text.insert('insert', '1', 'linenumber')
        self.sidebar_text.config(takefocus=False, exportselection=False)
        self.sidebar_text.tag_config('linenumber', justify=tk.RIGHT)

        end = get_end_linenumber(self.text)
        self.update_sidebar_text(end)

        return self.sidebar_text

    def grid(self):
        self.sidebar_text.grid(row=1, column=0, sticky=tk.NSEW)

    def update_font(self):
        font = idleConf.GetFont(self.text, 'main', 'EditorWindow')
        self.sidebar_text['font'] = font

    def update_colors(self):
        
        colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber')
        foreground = colors['foreground']
        background = colors['background']
        self.sidebar_text.config(
            fg=foreground, bg=background,
            selectforeground=foreground, selectbackground=background,
            inactiveselectbackground=background,
        )

    def update_sidebar_text(self, end):
        
        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)

        with temp_enable_text_widget(self.sidebar_text):
            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.prev_end = end

    def yscroll_event(self, *args, **kwargs):
        self.sidebar_text.yview_moveto(args[0])
        return 'break'


class WrappedLineHeightChangeDelegator(Delegator):
    def __init__(self, callback):
        
        Delegator.__init__(self)
        self.callback = callback

    def insert(self, index, chars, tags=None):
        is_single_line = '\n' not in chars
        if is_single_line:
            before_displaylines = get_displaylines(self, index)

        self.delegate.insert(index, chars, tags)

        if is_single_line:
            after_displaylines = get_displaylines(self, index)
            if after_displaylines == before_displaylines:
                return  

        self.callback()

    def delete(self, index1, index2=None):
        if index2 is None:
            index2 = index1 + "+1c"
        is_single_line = get_lineno(self, index1) == get_lineno(self, index2)
        if is_single_line:
            before_displaylines = get_displaylines(self, index1)

        self.delegate.delete(index1, index2)

        if is_single_line:
            after_displaylines = get_displaylines(self, index1)
            if after_displaylines == before_displaylines:
                return  

        self.callback()


class ShellSidebar(BaseSideBar):
    
    def __init__(self, editwin):
        self.canvas = None
        self.line_prompts = {}

        super().__init__(editwin)

        change_delegator = \
            WrappedLineHeightChangeDelegator(self.change_callback)
        
        
        d = self.editwin.per.top
        if d.delegate is not self.text:
            while d.delegate is not self.editwin.per.bottom:
                d = d.delegate
        self.editwin.per.insertfilterafter(change_delegator, after=d)

        self.is_shown = True

    def init_widgets(self):
        self.canvas = tk.Canvas(self.parent, width=30,
                                borderwidth=0, highlightthickness=0,
                                takefocus=False)
        self.update_sidebar()
        self.grid()
        return self.canvas

    def bind_events(self):
        super().bind_events()

        self.main_widget.bind(
            
            "<Button-2>" if macosx.isAquaTk() else "<Button-3>",
            self.context_menu_event,
        )

    def context_menu_event(self, event):
        rmenu = tk.Menu(self.main_widget, tearoff=0)
        has_selection = bool(self.text.tag_nextrange('sel', '1.0'))
        def mkcmd(eventname):
            return lambda: self.text.event_generate(eventname)
        rmenu.add_command(label='Copy',
                          command=mkcmd('<<copy>>'),
                          state='normal' if has_selection else 'disabled')
        rmenu.add_command(label='Copy with prompts',
                          command=mkcmd('<<copy-with-prompts>>'),
                          state='normal' if has_selection else 'disabled')
        rmenu.tk_popup(event.x_root, event.y_root)
        return "break"

    def grid(self):
        self.canvas.grid(row=1, column=0, sticky=tk.NSEW, padx=2, pady=0)

    def change_callback(self):
        if self.is_shown:
            self.update_sidebar()

    def update_sidebar(self):
        text = self.text
        text_tagnames = text.tag_names
        canvas = self.canvas
        line_prompts = self.line_prompts = {}

        canvas.delete(tk.ALL)

        index = text.index("@0,0")
        if index.split('.', 1)[1] != '0':
            index = text.index(f'{index}+1line linestart')
        while (lineinfo := text.dlineinfo(index)) is not None:
            y = lineinfo[1]
            prev_newline_tagnames = text_tagnames(f"{index} linestart -1c")
            prompt = (
                '>>>' if "console" in prev_newline_tagnames else
                '...' if "stdin" in prev_newline_tagnames else
                None
            )
            if prompt:
                canvas.create_text(2, y, anchor=tk.NW, text=prompt,
                                   font=self.font, fill=self.colors[0])
                lineno = get_lineno(text, index)
                line_prompts[lineno] = prompt
            index = text.index(f'{index}+1line')

    def yscroll_event(self, *args, **kwargs):
        
        self.change_callback()
        return 'break'

    def update_font(self):
        
        font = idleConf.GetFont(self.text, 'main', 'EditorWindow')
        tk_font = Font(self.text, font=font)
        char_width = max(tk_font.measure(char) for char in ['>', '.'])
        self.canvas.configure(width=char_width * 3 + 4)
        self.font = font
        self.change_callback()

    def update_colors(self):
        
        linenumbers_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber')
        prompt_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'console')
        foreground = prompt_colors['foreground']
        background = linenumbers_colors['background']
        self.colors = (foreground, background)
        self.canvas.configure(background=background)
        self.change_callback()


def _linenumbers_drag_scrolling(parent):  
    from idlelib.idle_test.test_sidebar import Dummy_editwin

    top = tk.Toplevel(parent)
    text_frame = tk.Frame(top)
    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(top, '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)