Tkinter Adding Line Number to Text Widget

Tkinter adding line number to text widget

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()

Adding line numbers to text widget using Tkinter

You mentioned this great example, why not just modify it to suit your needs? I was curious and modified the Example class from given link by adding a button to call a function load_xml which loads files via filechooser, deletes the previous data in the CustomText widget and inserts the new data:

import tkinter as tk
from tkinter import filedialog
import os

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)

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

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)

# new button to call load_xml and show status
self.load_button = tk.Button(root, text="Load file", command=self.load_xml)
self.load_button.pack(side="top")

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()

def load_xml(self):
"""Load any file, delete current text and insert new data"""
input_file = filedialog.askopenfilename(title="Load a textfile",
filetypes=(("XML", "*.xml"),
("Text", "*.txt"),
("All files", "*.*")),
initialdir=os.getcwd())

if input_file:
self.text.delete("1.0", "end")
self.load_button.config(text=f"Currently loaded: {input_file.split(os.sep)[-1]}")
with open(input_file, 'r') as f:
self.text.insert("end", f.read())

if __name__ == "__main__":
root = tk.Tk()
Example(root).pack(side="top", fill="both", expand=True)
root.mainloop()

How to display line numbers in tkinter.Text widget?

The root problem is that you're calling dlineinfo before returning to the runloop, so the text hasn't been laid out yet.

As the docs explain:

This method only works if the text widget is updated. To make sure this is the case, you can call the update_idletasks method first.

As usual, to get more information, you have to turn to the Tcl docs for the underlying object, which basically tell you that the Text widget may not be correct about which characters are and are not visible until it's updated, in which case it may be returning None not because of any problem, but just because, as far as it's concerned, you're asking for the bbox of something that's off-screen.

A good way to test whether this is the problem is to call self.__text.see(i) before calling dlineinfo(i). If it changes the result of dlineinfo, this was the problem. (Or, if not that, at least something related to that—for whatever reason, Tk thinks everything after line 1 is off-screen.)

But in this case, even calling update_idletasks doesn't work, because it's not just updating the line info that needs to happen, but laying out the text in the first place. What you need to do is explicitly defer this call. For example, add this line to the bottom of load_from_file and now it works:

self.__text.after(0, self.__update_line_numbers)

You could also call self.__text.update() before calling self.__update_line_numbers() inline, and I think that should work.


As a side note, it would really help you to either run this under the debugger, or add a print(i, dline) at the top of the loop, so you can see what you're getting, instead of just guessing.

Also wouldn't it be easier to just increment a linenumber and use '{}.0'.format(linenumber) instead of creating complex indexes like @0,0+1line+1line+1line that (at least for me) don't work. You can call Text.index() to convert any index to canonical format, but why make it so difficult? You know that what you want is 1.0, 2.0, 3.0, etc., right?

line number spacing in text widget Tkinter

Hopefully, Bryan Oakley will see your question and post an eloquent solution. But in the mean time, this somewhat hacky approach works. :)

On each redraw, we grab the current text contents of the widget, and build a dictionary that uses the index of each non-blank line as the key in a dictionary that holds the actual line numbers we want.

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

# Build dict to convert line index to line number
linenums = {}
num = 1
contents = self.textwidget.get("1.0", tk.END)
for i, line in enumerate(contents.splitlines(), 1):
i = str(i) + '.0'
# Only increment line number if the line's not blank
linetext = self.textwidget.get(i, "%s+1line" % i)
if linetext.strip():
linenums[i] = str(num)
#print num, linetext,
num += 1

i = self.textwidget.index("@0,0")
while True :
dline = self.textwidget.dlineinfo(i)
if dline is None:
break

linenum = linenums.get(i)
if linenum is not None:
y = dline[1]
self.create_text(2,y,anchor="nw", text=linenum)

i = self.textwidget.index("%s+1line" % i)

If you un-comment the #print num, linetext, line it will print each non-blank line with its number on every redraw. You probably don't want that, but it should help you to figure out how to extract the line numbers for a given line.

Multiline insert selection tkinter Text widget

With the help of this answer by Bryan Oakley, I've solved this problem. I'll explain the code and how it works, but if you just want the working code, it'll be at the bottom of this answer.


In order to comment/uncomment all the lines in the user's selection, we need to know each line number. This can be done by using Python's range() function and the start and end line numbers of the user's selection.

To get the start line of the user's selection, we use this code:

first_line = self.text.index("sel.first").split(".")[0]

sel.first just means "the start index of the user's selection." Similarly, if we want to get the end line of the user's selection, we do the same thing but use sel.last:

last_line = self.text.index("sel.last").split(".")[0]

Then, we use the range() function to loop through both of those lines and each line in between them:

for line in range(first_line, last_line + 1):
# Comment or uncomment each line
...

Note we use last_line + 1 to make sure we're including the last line, since Python's range() function stops before it gets to the second number.

Now, the only problem with this code is that if the user has not selected something, you get a _tkinter.TclError saying that text doesn't contain any characters tagged with "sel". So in order to be able to comment/uncomment a single line, you need to insert a try/except block:

try:
first_line = self.text.index("sel.first").split(".")[0]
last_line = self.text.index("sel.last").split(".")[0]
for line in range(first_line, last_line + 1):
# Comment or uncomment each line
...
except tkinter.TclError:
# Comment or uncomment a single line
...

The rest of the code looks very similar to what you already have; you check if the first character on the line is a #, if it is, then uncomment the line, if not, then comment the line.

There's just two more things:

  1. At the end of self.make_comment(), you might need to add return "break"; on my system at least, the Ctrl+/ command also selects all the text. Returning "break" at the end of the function prevents it from doing that.
  2. When looping and commenting multiple lines, you need to make sure that the current line number isn't greater than the number of newlines in the text. For example:
for line in range(first_line, last_line+1):
if line <= int(self.text.get("1.0", "end").count("\n")):
# Comment or uncomment the lines
...

All that said, here is a complete reproducible example. You can comment/uncomment multiple lines at a time, or just one line, depending on how things are selected:

import tkinter

class Window(tkinter.Tk):
"""The main window."""

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

# Our text widget
self.text = tkinter.Text(self)
with open(__file__) as f:
self.text.insert("1.0", f.read())
self.text.pack(expand=True, fill="both")

# Bind the text widget
self.text.bind("<Control-slash>", self.make_comment)
self.text.bind("<Control-a>", self.select_all)

def select_all(self, *args):
self.text.tag_add("sel", "1.0", "end")
return "break"

def make_comment(self, event):

# If this fails with an error, we know that the user only selected one line
try:

# Get the line number of the start and the end of the selection
first_line = int(self.text.index("sel.first").split(".")[0])
last_line = int(self.text.index("sel.last").split(".")[0])

# Loop through all the selected lines and comment or uncomment them
for line in range(first_line, last_line+1):
# This is to make sure that a # isn't added to something that isn't a line
if line <= int(self.text.get("1.0", "end").count("\n")):
if self.text.get("{}.0".format(line), "{}.1".format(line)) != "#":
self.text.insert("{}.0".format(line), "#")
else:
self.text.delete("{}.0".format(line), "{}.1".format(line))
except tkinter.TclError:

# Get the line number of the current cursor position
insert = self.text.index("insert").split(".")[0]

# Comment or uncomment the current line
if self.text.get("{}.0".format(insert), "{}.1".format(insert)) != "#":
self.text.insert("{}.0".format(insert), "#")
else:
self.text.delete("{}.0".format(insert), "{}.1".format(insert))
return "break"

if __name__ == "__main__":
Window().mainloop()

Tkinter text widget line numbers dissapear after adding new tab in Notebook

I forgot to bind functions in the addtab function.

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


Related Topics



Leave a reply



Submit