27

Trying to learn tkinter and python. I want to display line number for the Text widget in an adjacent frame

from Tkinter import *
root = Tk()
txt = Text(root)
txt.pack(expand=YES, fill=BOTH)
frame= Frame(root, width=25)
#

frame.pack(expand=NO, fill=Y, side=LEFT)
root.mainloop()

I have seen an example on a site called unpythonic but its assumes that line height of txt is 6 pixels.

I am trying something like this:

1) Binding Any-KeyPress event to a function that returns the line on which the keypress occurs:

textPad.bind("<Any-KeyPress>", linenumber)


def linenumber(event=None):
    line, column = textPad.index('end').split('.')
    #creating line number toolbar
    try:
       linelabel.pack_forget()
       linelabel.destroy()
       lnbar.pack_forget()
       lnbar.destroy()
    except:
      pass
   lnbar = Frame(root,  width=25)
   for i in range(0, len(line)):
      linelabel= Label(lnbar, text=i)
      linelabel.pack(side=LEFT)
      lnbar.pack(expand=NO, fill=X, side=LEFT)

Unfortunately this is giving some weird numbers on the frame. Is there a simpler solution? How to approach this?

Cœur
  • 37,241
  • 25
  • 195
  • 267
bhaskarc
  • 9,269
  • 10
  • 65
  • 86
  • Are you talking about having numbers embedded on the side by each line, then, all at once? Or do you just want to update the current line number in something like a statusbar? In the latter case, you can just do `myTextWidget.index(INSERT).split(".")[0]` and then you have the number to put wherever you want. If you want line numbers (rather than one number), you could always line up a parallel label without a border, print out every line on it, and have it move in sync with your main text widget, but I haven't tested that to see how well it works. – Brōtsyorfuzthrāx Jun 26 '14 at 09:43

6 Answers6

58

I have a relatively foolproof solution, but it's complex and will likely be hard to understand because it requires some knowledge of how Tkinter and the underlying tcl/tk text widget works. I'll present it here as a complete solution that you can use as-is because I think it illustrates a unique approach that works quite well.

Note that this solution works no matter what font you use, and whether or not you use different fonts on different lines, have embedded widgets, and so on.

Importing Tkinter

Before we get started, the following code assumes tkinter is imported like this if you're using python 3.0 or greater:

import tkinter as tk

... or this, for python 2.x:

import Tkinter as tk

The line number widget

Let's tackle the display of the line numbers. What we want to do is use a canvas so that we can position the numbers precisely. We'll create a custom class, and give it a new method named redraw that will redraw the line numbers for an associated text widget. We also give it a method attach, for associating a text widget with this widget.

This method takes advantage of the fact that the text widget itself can tell us exactly where a line of text starts and ends via the dlineinfo method. This can tell us precisely where to draw the line numbers on our canvas. It also takes advantage of the fact that dlineinfo returns None if a line is not visible, which we can use to know when to stop displaying line numbers.

class TextLineNumbers(tk.Canvas):
    def __init__(self, *args, **kwargs):
        tk.Canvas.__init__(self, *args, **kwargs)
        self.textwidget = None

    def attach(self, text_widget):
        self.textwidget = text_widget
        
    def redraw(self, *args):
        '''redraw line numbers'''
        self.delete("all")

        i = self.textwidget.index("@0,0")
        while True :
            dline= self.textwidget.dlineinfo(i)
            if dline is None: break
            y = dline[1]
            linenum = str(i).split(".")[0]
            self.create_text(2,y,anchor="nw", text=linenum)
            i = self.textwidget.index("%s+1line" % i)

If you associate this with a text widget and then call the redraw method, it should display the line numbers just fine.

Automatically updating the line numbers

This works, but has a fatal flaw: you have to know when to call redraw. You could create a binding that fires on every key press, but you also have to fire on mouse buttons, and you have to handle the case where a user presses a key and uses the auto-repeat function, etc. The line numbers also need to be redrawn if the window is grown or shrunk or the user scrolls, so we fall into a rabbit hole of trying to figure out every possible event that could cause the numbers to change.

There is another solution, which is to have the text widget fire an event whenever something changes. Unfortunately, the text widget doesn't have direct support for notifying the program of changes. To get around that, we can use a proxy to intercept changes to the text widget and generate an event for us.

In an answer to the question "https://stackoverflow.com/q/13835207/7432" I offered a similar solution that shows how to have a text widget call a callback whenever something changes. This time, instead of a callback we'll generate an event since our needs are a little different.

A custom text class

Here is a class that creates a custom text widget that will generate a <<Change>> event whenever text is inserted or deleted, or when the view is scrolled.

class CustomText(tk.Text):
    def __init__(self, *args, **kwargs):
        tk.Text.__init__(self, *args, **kwargs)

        # create a proxy for the underlying widget
        self._orig = self._w + "_orig"
        self.tk.call("rename", self._w, self._orig)
        self.tk.createcommand(self._w, self._proxy)

    def _proxy(self, *args):
        # let the actual widget perform the requested action
        cmd = (self._orig,) + args
        result = self.tk.call(cmd)

        # generate an event if something was added or deleted,
        # or the cursor position changed
        if (args[0] in ("insert", "replace", "delete") or 
            args[0:3] == ("mark", "set", "insert") or
            args[0:2] == ("xview", "moveto") or
            args[0:2] == ("xview", "scroll") or
            args[0:2] == ("yview", "moveto") or
            args[0:2] == ("yview", "scroll")
        ):
            self.event_generate("<<Change>>", when="tail")

        # return what the actual widget returned
        return result        

Putting it all together

Finally, here is an example program which uses these two classes:

class Example(tk.Frame):
    def __init__(self, *args, **kwargs):
        tk.Frame.__init__(self, *args, **kwargs)
        self.text = CustomText(self)
        self.vsb = tk.Scrollbar(self, orient="vertical", command=self.text.yview)
        self.text.configure(yscrollcommand=self.vsb.set)
        self.text.tag_configure("bigfont", font=("Helvetica", "24", "bold"))
        self.linenumbers = TextLineNumbers(self, width=30)
        self.linenumbers.attach(self.text)

        self.vsb.pack(side="right", fill="y")
        self.linenumbers.pack(side="left", fill="y")
        self.text.pack(side="right", fill="both", expand=True)

        self.text.bind("<<Change>>", self._on_change)
        self.text.bind("<Configure>", self._on_change)

        self.text.insert("end", "one\ntwo\nthree\n")
        self.text.insert("end", "four\n",("bigfont",))
        self.text.insert("end", "five\n")

    def _on_change(self, event):
        self.linenumbers.redraw()

... and, of course, add this at the end of the file to bootstrap it:

if __name__ == "__main__":
    root = tk.Tk()
    Example(root).pack(side="top", fill="both", expand=True)
    root.mainloop()
insolor
  • 424
  • 8
  • 16
Bryan Oakley
  • 370,779
  • 53
  • 539
  • 685
  • @ Bryan Oakley thanks a ton for detailed reply. Will try this now and report back to you. About your comment: ` to have the text widget fire an event whenever "something changes". Unfortunately, the text widget doesn't have direct support for that. ` I read that text method provides a modification option to check if a file has been modified using the "edit modified" method. see -[link](http://www.tkdocs.com/tutorial/text.html#more) Thanks again - off to see if i can handle it :) – bhaskarc May 04 '13 at 23:06
  • @ Bryan Oakley Your first solution worked perfectly for my needs, so i did not see the second solution. As to the question of when to call redraw, i called it on any-key press, and on cut, copy, paste, undo and redo events and that takes care of most of things - i guess :) – bhaskarc May 04 '13 at 23:52
  • @QuakiGabbar: yes, Tk throws an event when the widget is modified, but you would have to reset the "modified" state after every change, and then you wouldn't be able to use it for its real intended purpose of knowing when the contents are different from when it was originally loaded. – Bryan Oakley May 05 '13 at 01:42
  • @ Bryan Oakley thnks, i am witnessing a strange issue with the first linenumber code that you gave above. I displays perfectly well when the window size is small. But when i maximise the window to full screen , line number 1 starts displaying, from almost half screen down. I guess the problem is with lines: 'self.create_text(2,y,anchor="nw", text=linenum) i = self.textwidget.index("%s+1line" % i)'. but i am still to figure out. Any suggestions would be appreciated :) – bhaskarc May 05 '13 at 02:24
  • @ Bryan Oakley - sorry for bombarding questions :) I have heard that idle is written in tkinter. Where, if at all, can i find the source code for idle window ? i searched my python directory but could not locate it anywhere – bhaskarc May 05 '13 at 02:39
  • @QuakiGabbar: I can't reproduce the problem you say you have. Are you saying that the code I posted has a problem when run full screen, or your program which is adapted from this code has the problem? – Bryan Oakley May 05 '13 at 02:44
  • @ Bryan Oakley Thanks. can you please explain these 2 lines of code height = dline[3] & y = dline[1], i dont see you using height anywhere in the code after that, and if i insert a print y in my code, i see it jumps by 16 on every consecutive call to the function.(something like 2 2 | 2 18 | 2 18 34 | 2 18 34 50 | 2 18 34 50 66).why is this jump of 16. incidentally on a fullscreen, the line numbers stop displaying after line 16.. any ideas ? i had to change your code to procedural style, but that should not cause any problem i guess.. thanks again – bhaskarc May 05 '13 at 03:21
  • @QuakiGabbar: you are right; the line computing the height is useless; I was using it for something else in a different project. – Bryan Oakley May 05 '13 at 12:31
  • @QuakiGabbar: I'll ask again: is the problem with the line numbers stopping at half the screen a problem with the code in my post, or a problem in _your_ code? I don't see that problem with my code. – Bryan Oakley May 05 '13 at 12:32
  • @ Bryan Oakley:the problem should not have been in my code as i figured out a slightly different way of doing the line numbers using label rather than canvas. something like: `mylabel.config(text='') ln, col = mytext.index('end').split('.') txt='' for i in range(1, int(ln)): txt += str(i)+'\n' mylabel.config(text=txt)` works great..automatically takes care of line breaks with \n. thnks for help :) tc – bhaskarc May 05 '13 at 14:04
  • @BryanOakley Is it possible to change the text of the line numbers as well as the tkinter text widget. I tried using config and it doesn't work. – Henry Zhu Aug 30 '15 at 21:22
  • @Arshia: yes, of course you can change the text of the line numbers, and you can change the text in the text widget. There's nothing special about those widgets. The text widget works exactly like a standard widget, just with the extra virtual event. – Bryan Oakley Aug 31 '15 at 00:34
  • Very nice answer this really helped me. I especially liked that it python2/3 usable and you put python3 first. – magu_ Sep 25 '15 at 22:26
  • While this is a great answer, looks and works great, it does have performance issues, especially when you add lots of text to the text widget. Take a look at my answer below for an alternative way of doing this. :) – yelsayed May 07 '16 at 10:24
  • @YasserElsayed: have you actually _observed_ performance issues, or only think there are performance issues? It shouldn't matter how many lines of text you have because it only draws the linenumbers for the visible lines. I've tested it with 10,000 lines of text and it seems to work fine. Of course, if you enter 10,000 lines of text one at a time it will be slightly slower, but even then the performance seems acceptable. – Bryan Oakley May 07 '16 at 11:35
  • @BryanOakley I've played around with it for quite some time actually, and hit performance issues loading files. I do load lines one by one not the whole file at once, and I see an average of one to two seconds until the lines actually appear. It's worth mentioning I also do heavy tagging and coloring on the widget. – yelsayed May 07 '16 at 20:11
  • @YasserElsayed: the problem is in loading the text one line at a time. You are correct that this method makes that method slower, but there are two simple fixes: add the ability to turn this feature off during the initial loading (simply remove the `<>` binding at startup), or read a whole file and insert it in a single statement. – Bryan Oakley May 07 '16 at 22:13
  • @BryanOakley I'm trying to implement this code in one of my projects and I am having some issues. Just as an example, when I try to create a new file, I need to delete the text inside the custom text class but I get an attribute error when I use 'CustomText.delete("1.0", END)' that says 'str' object has no attribute 'tk'. Is there a way to add the change event to normal text widgets? That way I would still be able to use the functions that I created for new files and saving, etc. –  Nov 01 '18 at 17:24
  • @Shock9616: I don't know what you mean by "add the change event to normal text widgets" -- that's exactly what this code does: it adds a change event to a normal text widget. The `delete` method works fine with the code in this answer. In your comment it appears you're calling the `delete` method on the class rather than an instance of the class. – Bryan Oakley Nov 01 '18 at 17:31
  • @BryanOakley Oh! Right! That almost fixed it all. The only thing that isn't quite working right now is when I try to cut text, I get a TclError saying that the text doesn't contain any characters tagged with "sel". This error is being caused by the line in _proxy that says 'result = self.tk.call(cmd)' –  Nov 01 '18 at 18:09
  • Please help me with this error: (https://stackoverflow.com/questions/65228477/text-doesnt-contain-any-characters-tagged-with-sel-tkinter) – AwesomeSam Dec 10 '20 at 06:45
  • Would it be possible to use my own text buffer instead of the Text widget with your TextLineNumbers widget? I've been struggling to find any graceful way to use a hand-written buffer with tkinter, or any other Python GUI library that I can find. – quantumferret Feb 10 '21 at 21:19
  • @BryanOakley How could I make it so that the TextLineNumbers dynamically resized so that it can handle really large contents? – TRCK Jun 14 '22 at 01:57
  • @TRCK: I don't understand your question. The line numbers widget can be as tall as the physical screen, and can display any range of numbers up to the limit of what the text widget can hold. – Bryan Oakley Jun 14 '22 at 03:25
  • If you have more than 10,000 lines it is too big horizontally and starts to go over the edge – TRCK Jun 14 '22 at 13:11
  • @TRCK: have you tried simply making the line number canvas wider? – Bryan Oakley Jun 14 '22 at 13:40
  • @BryanOakley Well it would be better to dynamically resize the text size of the line numbers correct? – TRCK Jun 14 '22 at 14:42
4

Here's my attempt at doing the same thing. I tried Bryan Oakley's answer above, it looks and works great, but it comes at a price with performance. Everytime I'm loading lots of lines into the widget, it takes a long time to do that. In order to work around this, I used a normal Text widget to draw the line numbers, here's how I did it:

Create the Text widget and grid it to the left of the main text widget that you're adding the lines for, let's call it textarea. Make sure you also use the same font you use for textarea:

self.linenumbers = Text(self, width=3)
self.linenumbers.grid(row=__textrow, column=__linenumberscol, sticky=NS)
self.linenumbers.config(font=self.__myfont)

Add a tag to right-justify all lines added to the line numbers widget, let's call it line:

self.linenumbers.tag_configure('line', justify='right')

Disable the widget so that it cannot be edited by the user

self.linenumbers.config(state=DISABLED)

Now the tricky part is adding one scrollbar, let's call it uniscrollbar to control both the main text widget as well as the line numbers text widget. In order to do that, we first need two methods, one to be called by the scrollbar, which can then update the two text widgets to reflect the new position, and the other to be called whenever a text area is scrolled, which will update the scrollbar:

def __scrollBoth(self, action, position, type=None):
    self.textarea.yview_moveto(position)
    self.linenumbers.yview_moveto(position)

def __updateScroll(self, first, last, type=None):
    self.textarea.yview_moveto(first)
    self.linenumbers.yview_moveto(first)
    self.uniscrollbar.set(first, last)

Now we're ready to create the uniscrollbar:

    self.uniscrollbar= Scrollbar(self)
    self.uniscrollbar.grid(row=self.__uniscrollbarRow, column=self.__uniscrollbarCol, sticky=NS)
    self.uniscrollbar.config(command=self.__scrollBoth)
    self.textarea.config(yscrollcommand=self.__updateScroll)
    self.linenumbers.config(yscrollcommand=self.__updateScroll)

Voila! You now have a very lightweight text widget with line numbers:

enter image description here

yelsayed
  • 5,236
  • 3
  • 27
  • 38
  • 2
    I'm not sure how you are inserting the line numbers, but there is a problem with this solution if you use any kind of wrapping. Although you can calculate how many lines are wrapped in the visible text widget, it gets more difficult to calculate invisible wrapped lines above the visible code. Perhaps you could still calculate that using the scrollbar data, or somehow measuring the characters with bbox. So if using wrapping, I'd recommend a solution with Canvas. – Robin Manoli Jun 02 '16 at 16:13
  • Good observation, I missed that. You're right, although I'd still say this is better than using a canvas for performance. The way you can fix this is just calculate the wrapping character, since the size of the textbox is measured in number of characters anyway, rather than real pixel size. – yelsayed Jun 03 '16 at 16:35
2

I have seen an example on a site called unpythonic but its assumes that line height of txt is 6 pixels.

Compare:

# assume each line is at least 6 pixels high
step = 6

step - how often (in pixels) program check text widget for new lines. If height of line in text widget is 30 pixels, this program performs 5 checks and draw only one number.
You can set it to value that <6 if font is very small.
There is one condition: all symbols in text widget must use one font, and widget that draw numbers must use the same font.

# http://tkinter.unpythonic.net/wiki/A_Text_Widget_with_Line_Numbers
class EditorClass(object):
    ...


    self.lnText = Text(self.frame,
                    ...
                    state='disabled', font=('times',12))
    self.lnText.pack(side=LEFT, fill='y')
    # The Main Text Widget
    self.text = Text(self.frame,
                        bd=0,
                        padx = 4, font=('times',12))
    ...
kalgasnik
  • 3,149
  • 21
  • 19
2

After thoroughly reading through each solution mentioned here, and trying some of them out myself, I decided to use Brian Oakley's solution with some modifications. This might not be a more efficient solution, but should be enough for someone who is looking for a quick and easy to implement method, which is also simple in principle.

It draws the line's in the same manner, but instead of generating <<Change>> events, it simply binds the key press, scroll, left click events to the text as well as left click event to the scrollbar. In order to not glitch when, e.g. a paste command is performed, it then waits 2ms before actually redrawing the line numbers.

EDIT: This is also similair to FoxDot's solution, but instead of constantly refreshing the line numbers, they are only refreshed on the bound events

Below is an example code with delays implemented, along with my implementation of the scroll

import tkinter as tk


# This is a scrollable text widget
class ScrollText(tk.Frame):
    def __init__(self, master, *args, **kwargs):
        tk.Frame.__init__(self, *args, **kwargs)
        self.text = tk.Text(self, bg='#2b2b2b', foreground="#d1dce8", 
                            insertbackground='white',
                            selectbackground="blue", width=120, height=30)

        self.scrollbar = tk.Scrollbar(self, orient=tk.VERTICAL, command=self.text.yview)
        self.text.configure(yscrollcommand=self.scrollbar.set)

        self.numberLines = TextLineNumbers(self, width=40, bg='#313335')
        self.numberLines.attach(self.text)

        self.scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
        self.numberLines.pack(side=tk.LEFT, fill=tk.Y, padx=(5, 0))
        self.text.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)

        self.text.bind("<Key>", self.onPressDelay)
        self.text.bind("<Button-1>", self.numberLines.redraw)
        self.scrollbar.bind("<Button-1>", self.onScrollPress)
        self.text.bind("<MouseWheel>", self.onPressDelay)

    def onScrollPress(self, *args):
        self.scrollbar.bind("<B1-Motion>", self.numberLines.redraw)

    def onScrollRelease(self, *args):
        self.scrollbar.unbind("<B1-Motion>", self.numberLines.redraw)

    def onPressDelay(self, *args):
        self.after(2, self.numberLines.redraw)

    def get(self, *args, **kwargs):
        return self.text.get(*args, **kwargs)

    def insert(self, *args, **kwargs):
        return self.text.insert(*args, **kwargs)

    def delete(self, *args, **kwargs):
        return self.text.delete(*args, **kwargs)

    def index(self, *args, **kwargs):
        return self.text.index(*args, **kwargs)

    def redraw(self):
        self.numberLines.redraw()


'''THIS CODE IS CREDIT OF Bryan Oakley (With minor visual modifications on my side): 
https://stackoverflow.com/questions/16369470/tkinter-adding-line-number-to-text-widget'''


class TextLineNumbers(tk.Canvas):
    def __init__(self, *args, **kwargs):
        tk.Canvas.__init__(self, *args, **kwargs, highlightthickness=0)
        self.textwidget = None

    def attach(self, text_widget):
        self.textwidget = text_widget

    def redraw(self, *args):
        '''redraw line numbers'''
        self.delete("all")

        i = self.textwidget.index("@0,0")
        while True :
            dline= self.textwidget.dlineinfo(i)
            if dline is None: break
            y = dline[1]
            linenum = str(i).split(".")[0]
            self.create_text(2, y, anchor="nw", text=linenum, fill="#606366")
            i = self.textwidget.index("%s+1line" % i)


'''END OF Bryan Oakley's CODE'''

if __name__ == '__main__':
    root = tk.Tk()
    scroll = ScrollText(root)
    scroll.insert(tk.END, "HEY" + 20*'\n')
    scroll.pack()
    scroll.text.focus()
    root.after(200, scroll.redraw())
    root.mainloop()

Also, I noticed that with Brian Oakley's code, if you use the mouse wheel to scroll (and the scrollable text is full), the top number lines sometimes glitch out and get out of sync with the actual text, which is why I decided to add the delay in the first place. Though I only tested it on my own implementation of Scrolled Text widget, so this bug might be unique to my solution, although it is still peculiar

Willmish
  • 21
  • 3
1

There is a very simple method that I've used based on Bryan Oakley's answer above. Instead of listening for any changes made, simply "refresh" the widget using the self.after() method, which schedules a call after a number of milliseconds. Very simple way of doing it. In this instance I attach the text widget at instantation but you could do this later if you want.

class TextLineNumbers(tk.Canvas):
        def __init__(self, textwidget, *args, **kwargs):
        tk.Canvas.__init__(self, *args, **kwargs)
        self.textwidget = textwidget
        self.redraw()

    def redraw(self, *args):
        '''redraw line numbers'''
        self.delete("all")

        i = self.textwidget.index("@0,0")
        while True :
            dline= self.textwidget.dlineinfo(i)
            if dline is None: break
            y = dline[1]
            linenum = str(i).split(".")[0]
            self.create_text(2,y,anchor="nw", text=linenum)
            i = self.textwidget.index("%s+1line" % i)

        # Refreshes the canvas widget 30fps
        self.after(30, self.redraw)
FoxDot
  • 371
  • 3
  • 3
0

I used some object oriented approach and created custom class that handles text numeeration automatically, but it does not support wrapping of text content. It mainly uses events to handle new line changes in text content. So it has some restrictions but I think that its quite simple, so I decided to add it here.

Here is an example:

from tkinter import Frame, Text
import tkinter as tk

class CustomTextField(Frame):
    def __init__(self, parent, content, **kwargs):

        super(CustomTextField, self).__init__(parent, **kwargs)
        # lines numeration
        self.lines_no = len(content.split('\n')) # get initial lines number

        # create text widget for lines numeration
        self.numeration = Text(self, width=5)
        self.numeration.pack(side='left', fill='y')
        self.numeration.tag_configure("center", justify="center")
        self.numeration.insert('end', '\n'.join(str(x) for x in range(1, self.lines_no+1)), 'center')
        self.numeration.configure(state="disabled")

        # text content
        self.content = Text(self, wrap='none')
        self.content.pack(side='left', fill='y', expand=True)
        self.content.insert('end', content)

        # event handling
        self.content.bind("<KeyPress>", self.handle_new_line)

    ########<METHODS>#################

    def handle_new_line(self, event):
        # enable numeration editing
        self.numeration.configure(state="normal")
        if event.keysym == 'BackSpace' and event.state == 0 and self.numeration.get('insert') == '\n': # when new line is deleted
            self.lines_no = self.lines_no-1
            self.numeration.delete("end-2c linestart", "end")
        elif event.keysym == 'Return'and (event.state == 0 or event.state == 1): # when new line is entered
            self.lines_no = self.lines_no+1
            self.numeration.insert('end', '\n' + str(self.lines_no), 'center')
        self.numeration.configure(state="disabled")

# TESTS - I used jupyter notebook so it is in this form
# some random content
content = '''Tiberius III (died c. 706) was Byzantine emperor from 698 to 705. He was a mid-level 
commander who served in the Cibyrrhaeot Theme. In 696, he was part of an army sent by 
Emperor Leontius to retake Carthage from the Umayyads. After seizing the city, the army
was'''

# Create the main window
root = tk.Tk()

# Create the ScrolledText widget
text_widget = CustomTextField(root, content)
text_widget.pack(fill=tk.BOTH, expand=True) 



# Start the Tkinter event loop
root.mainloop()
Kuba Jjj
  • 31
  • 4